agent_client_protocol/session/v2.rs
1use std::{future::Future, path::Path};
2
3use futures::{
4 channel::oneshot,
5 future::{self, Either},
6};
7
8use crate::{
9 Agent, Client, ConnectionTo as RawConnectionTo, DynamicHandlerGuard, JsonRpcRequest, Responder,
10 SentRequest, V2,
11 jsonrpc::run::{NullRun, RunWithConnectionTo},
12 role::{HasPeer, acp::ProxySessionMessages},
13 v2::ConnectionTo,
14 v2::schema as v2,
15};
16
17#[cfg(feature = "unstable_mcp_over_acp")]
18use crate::{jsonrpc::run::ChainRun, mcp_server::McpServer};
19
20use super::{
21 SessionCleanup, close_session_registrations, drive_session_cleanup,
22 run_attached_session_runner, session_cleanup, session_runner_connection, wait_session_cleanup,
23};
24
25async fn run_pending_session_setup<Counterpart, Run>(
26 connection: RawConnectionTo<Counterpart>,
27 run: Run,
28 started_tx: oneshot::Sender<Result<(), crate::Error>>,
29 promotion_rx: oneshot::Receiver<()>,
30 cleanup: SessionCleanup,
31) -> Result<(), crate::Error>
32where
33 Counterpart: HasPeer<Agent>,
34 Run: RunWithConnectionTo<Counterpart>,
35{
36 let (connection, error_scope) = session_runner_connection(connection, &cleanup);
37 let mut run = Box::pin(run.run_with_connection_to(connection));
38 let first_poll =
39 future::poll_fn(|cx| std::task::Poll::Ready(std::future::Future::poll(run.as_mut(), cx)))
40 .await;
41 // A composed runner may have observed an immediate error but remain Pending
42 // while its sibling drives owned cleanup. It still failed before publication.
43 if let Some(error) = error_scope.error() {
44 close_session_registrations(&cleanup);
45 if first_poll.is_pending() {
46 drop(drive_session_cleanup(run, cleanup).await);
47 } else {
48 wait_session_cleanup(cleanup).await;
49 }
50 drop(started_tx.send(Err(error)));
51 return Ok(());
52 }
53 let readiness = match &first_poll {
54 std::task::Poll::Ready(result) => result.clone(),
55 std::task::Poll::Pending => Ok(()),
56 };
57 drop(started_tx.send(readiness));
58
59 match first_poll {
60 std::task::Poll::Ready(Ok(())) => {
61 if promotion_rx.await.is_err() {
62 close_session_registrations(&cleanup);
63 wait_session_cleanup(cleanup).await;
64 }
65 Ok(())
66 }
67 // The request has not been published yet, so report an immediate
68 // startup failure through its readiness result without failing the
69 // whole connection.
70 std::task::Poll::Ready(Err(_)) => {
71 close_session_registrations(&cleanup);
72 wait_session_cleanup(cleanup).await;
73 Ok(())
74 }
75 std::task::Poll::Pending => match future::select(run, promotion_rx).await {
76 Either::Left((result, promotion_rx)) => {
77 // A Pending first poll releases session setup for publication.
78 // From that point onward the agent may already be using an
79 // attachment, so runner failures are connection-fatal just as
80 // they are for other connection runners.
81 if result.is_err() || promotion_rx.await.is_err() {
82 close_session_registrations(&cleanup);
83 wait_session_cleanup(cleanup).await;
84 }
85 result
86 }
87 Either::Right((Ok(()), run)) => {
88 run_attached_session_runner(run, cleanup, error_scope).await
89 }
90 Either::Right((Err(_), run)) => {
91 close_session_registrations(&cleanup);
92 run_attached_session_runner(run, cleanup, error_scope).await
93 }
94 },
95 }
96}
97
98fn send_session_setup<Counterpart, Request, Run>(
99 connection: ConnectionTo<Counterpart>,
100 request: Request,
101 dynamic_handler_registrations: Vec<DynamicHandlerGuard<Counterpart>>,
102 run: Run,
103 ordered: bool,
104) -> SentRequest<Request::Response, V2>
105where
106 Counterpart: HasPeer<Agent>,
107 Request: JsonRpcRequest + 'static,
108 Request::Response: 'static,
109 Run: RunWithConnectionTo<Counterpart> + 'static,
110{
111 let raw_connection = connection.raw_connection().clone();
112 if dynamic_handler_registrations.is_empty() {
113 drop(run);
114 if ordered {
115 raw_connection.send_ordered_request_to(Agent, request)
116 } else {
117 connection.send_request_to(Agent, request)
118 }
119 } else {
120 let handlers_ready = raw_connection.dynamic_handler_barrier();
121 let (runner_started_tx, runner_started_rx) = oneshot::channel();
122 let (promotion_tx, promotion_rx) = oneshot::channel();
123 let cleanup = session_cleanup(&dynamic_handler_registrations);
124 let runner_started = match raw_connection.spawn(run_pending_session_setup(
125 raw_connection.clone(),
126 run,
127 runner_started_tx,
128 promotion_rx,
129 cleanup,
130 )) {
131 Ok(()) => Either::Left(async move {
132 runner_started_rx.await.map_err(|error| {
133 crate::util::internal_error(format!(
134 "session setup runner stopped before its initial poll: {error}"
135 ))
136 })?
137 }),
138 Err(error) => Either::Right(future::ready(Err(error))),
139 };
140 let readiness = async move {
141 future::try_join(handlers_ready, runner_started).await?;
142 Ok(())
143 };
144 let response_hook = move |_response: &Request::Response| {
145 promotion_tx.send(()).map_err(|()| {
146 crate::util::internal_error("session setup runner stopped before setup completed")
147 })?;
148 dynamic_handler_registrations
149 .into_iter()
150 .for_each(DynamicHandlerGuard::detach);
151 Ok(())
152 };
153
154 if ordered {
155 raw_connection.send_ordered_request_to_with_response_hook_after(
156 Agent,
157 request,
158 readiness,
159 response_hook,
160 )
161 } else {
162 raw_connection.send_request_to_with_response_hook_after(
163 Agent,
164 request,
165 readiness,
166 response_hook,
167 )
168 }
169 }
170}
171
172impl<Counterpart> ConnectionTo<Counterpart>
173where
174 Counterpart: HasPeer<Agent>,
175{
176 /// Build a draft protocol v2 `session/new` request.
177 ///
178 /// `cwd` must be an absolute path: ACP requires one, and the SDK sends it
179 /// without checking.
180 pub fn build_session(&self, cwd: impl AsRef<Path>) -> SessionBuilder<Counterpart> {
181 SessionBuilder::new(self, v2::NewSessionRequest::new(cwd.as_ref()))
182 }
183
184 /// Build a draft protocol v2 session using the current working directory.
185 ///
186 /// Returns an error if the current directory cannot be determined.
187 pub fn build_session_cwd(&self) -> Result<SessionBuilder<Counterpart>, crate::Error> {
188 let cwd = std::env::current_dir().map_err(|error| {
189 crate::Error::internal_error().data(format!("cannot get current directory: {error}"))
190 })?;
191 Ok(self.build_session(cwd))
192 }
193
194 /// Build a draft protocol v2 session from an existing `session/new` request.
195 pub fn build_session_from(
196 &self,
197 request: v2::NewSessionRequest,
198 ) -> SessionBuilder<Counterpart> {
199 SessionBuilder::new(self, request)
200 }
201
202 /// Build an unstable draft protocol v2 `session/fork` request.
203 ///
204 /// This helper is available with the `unstable_session_fork` feature. Call
205 /// [`ForkSessionBuilder::start_session`] to publish the request and
206 /// obtain a command handle for the newly created fork.
207 ///
208 /// `cwd` must be an absolute path: ACP requires one, and the SDK sends it
209 /// without checking.
210 #[cfg(feature = "unstable_session_fork")]
211 pub fn fork_session(
212 &self,
213 session_id: impl Into<v2::SessionId>,
214 cwd: impl AsRef<Path>,
215 ) -> ForkSessionBuilder<Counterpart> {
216 self.fork_session_from(v2::ForkSessionRequest::new(session_id, cwd.as_ref()))
217 }
218
219 /// Build an unstable draft protocol v2 `session/fork` request from an
220 /// existing request.
221 ///
222 /// This helper is available with the `unstable_session_fork` feature. Call
223 /// [`ForkSessionBuilder::start_session`] to publish the request.
224 #[cfg(feature = "unstable_session_fork")]
225 pub fn fork_session_from(
226 &self,
227 request: v2::ForkSessionRequest,
228 ) -> ForkSessionBuilder<Counterpart> {
229 ForkSessionBuilder::new(self, request)
230 }
231
232 /// Build a draft protocol v2 `session/resume` request.
233 ///
234 /// Use [`Self::resume_session_from`] to request history replay or set
235 /// other optional resume parameters. Call
236 /// [`ResumeSessionBuilder::start_session`] to publish the request.
237 ///
238 /// `cwd` must be an absolute path: ACP requires one, and the SDK sends it
239 /// without checking.
240 pub fn resume_session(
241 &self,
242 session_id: impl Into<v2::SessionId>,
243 cwd: impl AsRef<Path>,
244 ) -> ResumeSessionBuilder<Counterpart> {
245 self.resume_session_from(v2::ResumeSessionRequest::new(session_id, cwd.as_ref()))
246 }
247
248 /// Build a draft protocol v2 `session/resume` request from an existing request.
249 ///
250 /// Register typed session update handlers before connecting. When the
251 /// request asks for replay, the agent sends those updates before the
252 /// [`v2::schema::ResumeSessionResponse`](v2::ResumeSessionResponse). Call
253 /// [`ResumeSessionBuilder::start_session`] to publish the request.
254 pub fn resume_session_from(
255 &self,
256 request: v2::ResumeSessionRequest,
257 ) -> ResumeSessionBuilder<Counterpart> {
258 ResumeSessionBuilder::new(self, request)
259 }
260}
261
262/// Builder for a draft protocol v2 `session/new` request.
263///
264/// Protocol v2 acknowledges `session/prompt` independently from inbound
265/// session updates. Register typed [`v2::schema::UpdateSessionNotification`](v2::UpdateSessionNotification) and
266/// session request handlers on [`v2::Builder`](crate::v2::Builder) before connecting, then use
267/// [`Self::start_session`] to create the command-only [`ActiveSession`] handle or
268/// `on_proxy_session_start` to forward setup through a proxy.
269///
270/// With both the `unstable_protocol_v2` and `unstable_mcp_over_acp` features,
271/// `with_mcp_server` attaches an MCP server to the new session.
272#[must_use = "call `start_session` or `on_proxy_session_start` to send the `session/new` request"]
273#[derive(Debug)]
274pub struct SessionBuilder<Counterpart, Run = NullRun>
275where
276 Counterpart: HasPeer<Agent>,
277 Run: RunWithConnectionTo<Counterpart>,
278{
279 connection: ConnectionTo<Counterpart>,
280 request: v2::NewSessionRequest,
281 dynamic_handler_registrations: Vec<DynamicHandlerGuard<Counterpart>>,
282 run: Run,
283}
284
285impl<Counterpart> SessionBuilder<Counterpart, NullRun>
286where
287 Counterpart: HasPeer<Agent>,
288{
289 fn new(connection: &ConnectionTo<Counterpart>, request: v2::NewSessionRequest) -> Self {
290 Self {
291 connection: connection.clone(),
292 request,
293 dynamic_handler_registrations: Vec::new(),
294 run: NullRun,
295 }
296 }
297}
298
299impl<Counterpart, Run> SessionBuilder<Counterpart, Run>
300where
301 Counterpart: HasPeer<Agent>,
302 Run: RunWithConnectionTo<Counterpart>,
303{
304 /// Attach an MCP server to this new protocol v2 session.
305 ///
306 /// This method is available when both `unstable_protocol_v2` and
307 /// `unstable_mcp_over_acp` are enabled. MCP routes are installed and their
308 /// runner tasks receive an initial poll before `session/new` is published,
309 /// allowing the agent to connect while handling session setup. A
310 /// successful attachment remains active for the lifetime of the connection.
311 ///
312 /// The declaration is an `acp` transport (`McpServer::Acp`). The agent must
313 /// advertise `session.mcp.acp`, or a compatibility proxy such as
314 /// `McpOverAcpPolyfill` must sit in front of it; otherwise a conforming
315 /// agent rejects the setup request with `-32602`. This method does not
316 /// check the agent's capabilities.
317 #[cfg(feature = "unstable_mcp_over_acp")]
318 pub fn with_mcp_server<McpRun>(
319 mut self,
320 mcp_server: McpServer<Counterpart, McpRun>,
321 ) -> Result<SessionBuilder<Counterpart, ChainRun<Run, McpRun>>, crate::Error>
322 where
323 McpRun: RunWithConnectionTo<Counterpart>,
324 {
325 let (handler, mcp_run) = mcp_server.into_v2_handler_and_runner();
326 self.dynamic_handler_registrations
327 .push(handler.into_dynamic_handler(&mut self.request.mcp_servers, &self.connection)?);
328 Ok(SessionBuilder {
329 connection: self.connection,
330 request: self.request,
331 dynamic_handler_registrations: self.dynamic_handler_registrations,
332 run: ChainRun::new(self.run, mcp_run),
333 })
334 }
335
336 fn send_new_session(self, ordered: bool) -> SentRequest<v2::NewSessionResponse, V2>
337 where
338 Run: 'static,
339 {
340 let Self {
341 connection,
342 request,
343 dynamic_handler_registrations,
344 run,
345 } = self;
346 send_session_setup(
347 connection,
348 request,
349 dynamic_handler_registrations,
350 run,
351 ordered,
352 )
353 }
354
355 /// Send `session/new` and return its independently consumable request.
356 ///
357 /// The successful result contains both a cloneable command handle and the
358 /// complete [`v2::schema::NewSessionResponse`](v2::NewSessionResponse). Consume the returned request with
359 /// [`SentRequest::block_task`], [`SentRequest::on_receiving_result`], or
360 /// another explicit [`SentRequest`] completion mode.
361 ///
362 /// Attached MCP routes are installed and their runner tasks begin
363 /// executing before the request is published. A valid success response
364 /// promotes them to the connection lifetime, independently from how this
365 /// request handle is consumed. Setup errors clean up the pending
366 /// attachment.
367 pub fn start_session(self) -> SentRequest<SessionSetup<Counterpart, v2::NewSessionResponse>, V2>
368 where
369 Run: 'static,
370 {
371 let session_connection = self.connection.clone();
372 self.send_new_session(false).map(move |response| {
373 let session = ActiveSession {
374 session_id: response.session_id.clone(),
375 connection: session_connection,
376 };
377 Ok(SessionSetup { session, response })
378 })
379 }
380
381 /// Start a protocol v2 session through a proxy and forward its response.
382 ///
383 /// The downstream request is ordered and inherits cancellation from the
384 /// upstream request. On success, this helper installs session routing before
385 /// later inbound traffic is processed, forwards the complete response, and
386 /// spawns `op` with an [`SessionSetup`] containing the command-only
387 /// session handle plus the complete setup response. Inbound updates and
388 /// interactive requests remain independent connection traffic.
389 ///
390 /// The callback runs outside the ordered response barrier, so it may wait
391 /// for later connection traffic without deadlocking the dispatch loop.
392 pub fn on_proxy_session_start<F, Fut>(
393 self,
394 responder: Responder<v2::NewSessionResponse, V2>,
395 op: F,
396 ) -> Result<(), crate::Error>
397 where
398 Counterpart: HasPeer<Client>,
399 Run: 'static,
400 F: FnOnce(SessionSetup<Counterpart, v2::NewSessionResponse>) -> Fut + Send + 'static,
401 Fut: Future<Output = Result<(), crate::Error>> + Send,
402 {
403 let session_connection = self.connection.clone();
404 self.send_new_session(true)
405 .forward_cancellation_from(responder.cancellation())
406 .on_receiving_ok_result(responder, async move |response, responder| {
407 let session_id = response.session_id.clone();
408 let raw_connection = session_connection.raw_connection();
409 let route = match raw_connection.add_dynamic_handler(ProxySessionMessages::new(
410 crate::v1::schema::SessionId::from(session_id.0.clone()),
411 )) {
412 Ok(route) => route,
413 Err(error) => return responder.respond_with_error(error),
414 };
415
416 let setup = SessionSetup {
417 session: ActiveSession {
418 session_id,
419 connection: session_connection.clone(),
420 },
421 response: response.clone(),
422 };
423 responder.respond(response)?;
424 route.detach();
425 raw_connection.spawn(async move { op(setup).await })
426 })
427 }
428}
429
430/// Builder for an unstable draft protocol v2 `session/fork` request.
431///
432/// A successful fork creates a new independent session whose ID comes from the
433/// [`v2::schema::ForkSessionResponse`](v2::ForkSessionResponse). Register typed
434/// [`v2::schema::UpdateSessionNotification`](v2::UpdateSessionNotification) and interactive request handlers on
435/// [`v2::Builder`](crate::v2::Builder) before connecting, then use [`Self::start_session`] to
436/// obtain a command handle for the fork or `on_proxy_session_start` to forward
437/// setup through a proxy.
438///
439/// This type is available with the `unstable_session_fork` feature. With
440/// `unstable_mcp_over_acp` as well, `with_mcp_server` attaches an MCP server to
441/// the forked session.
442#[cfg(feature = "unstable_session_fork")]
443#[must_use = "call `start_session` or `on_proxy_session_start` to send the `session/fork` request"]
444#[derive(Debug)]
445pub struct ForkSessionBuilder<Counterpart, Run = NullRun>
446where
447 Counterpart: HasPeer<Agent>,
448 Run: RunWithConnectionTo<Counterpart>,
449{
450 connection: ConnectionTo<Counterpart>,
451 request: v2::ForkSessionRequest,
452 dynamic_handler_registrations: Vec<DynamicHandlerGuard<Counterpart>>,
453 run: Run,
454}
455
456#[cfg(feature = "unstable_session_fork")]
457impl<Counterpart> ForkSessionBuilder<Counterpart, NullRun>
458where
459 Counterpart: HasPeer<Agent>,
460{
461 fn new(connection: &ConnectionTo<Counterpart>, request: v2::ForkSessionRequest) -> Self {
462 Self {
463 connection: connection.clone(),
464 request,
465 dynamic_handler_registrations: Vec::new(),
466 run: NullRun,
467 }
468 }
469}
470
471#[cfg(feature = "unstable_session_fork")]
472impl<Counterpart, Run> ForkSessionBuilder<Counterpart, Run>
473where
474 Counterpart: HasPeer<Agent>,
475 Run: RunWithConnectionTo<Counterpart>,
476{
477 /// Attach an MCP server to this forked protocol v2 session.
478 ///
479 /// This method is available when `unstable_mcp_over_acp` is enabled in
480 /// addition to `unstable_protocol_v2` and `unstable_session_fork`. MCP
481 /// routes are installed and their runner tasks receive an initial poll
482 /// before `session/fork` is published, allowing the agent to connect while
483 /// handling session setup. A successful attachment remains active for the
484 /// lifetime of the connection.
485 ///
486 /// The declaration is an `acp` transport (`McpServer::Acp`). The agent must
487 /// advertise `session.mcp.acp`, or a compatibility proxy such as
488 /// `McpOverAcpPolyfill` must sit in front of it; otherwise a conforming
489 /// agent rejects the setup request with `-32602`. This method does not
490 /// check the agent's capabilities.
491 #[cfg(feature = "unstable_mcp_over_acp")]
492 pub fn with_mcp_server<McpRun>(
493 mut self,
494 mcp_server: McpServer<Counterpart, McpRun>,
495 ) -> Result<ForkSessionBuilder<Counterpart, ChainRun<Run, McpRun>>, crate::Error>
496 where
497 McpRun: RunWithConnectionTo<Counterpart>,
498 {
499 let (handler, mcp_run) = mcp_server.into_v2_handler_and_runner();
500 self.dynamic_handler_registrations
501 .push(handler.into_dynamic_handler(&mut self.request.mcp_servers, &self.connection)?);
502 Ok(ForkSessionBuilder {
503 connection: self.connection,
504 request: self.request,
505 dynamic_handler_registrations: self.dynamic_handler_registrations,
506 run: ChainRun::new(self.run, mcp_run),
507 })
508 }
509
510 fn send_fork_session(self, ordered: bool) -> SentRequest<v2::ForkSessionResponse, V2>
511 where
512 Run: 'static,
513 {
514 let Self {
515 connection,
516 request,
517 dynamic_handler_registrations,
518 run,
519 } = self;
520 send_session_setup(
521 connection,
522 request,
523 dynamic_handler_registrations,
524 run,
525 ordered,
526 )
527 }
528
529 /// Send `session/fork` and return its independently consumable request.
530 ///
531 /// The successful result contains both a cloneable command handle for the
532 /// newly created fork and the complete [`v2::schema::ForkSessionResponse`](v2::ForkSessionResponse). Consume
533 /// the returned request with [`SentRequest::block_task`],
534 /// [`SentRequest::on_receiving_result`], or another explicit [`SentRequest`]
535 /// completion mode.
536 ///
537 /// Attached MCP routes are installed and their runner tasks begin
538 /// executing before the request is published. A valid success response
539 /// promotes them to the connection lifetime independently from how this
540 /// request handle is consumed; setup errors clean up the pending
541 /// attachment.
542 pub fn start_session(
543 self,
544 ) -> SentRequest<SessionSetup<Counterpart, v2::ForkSessionResponse>, V2>
545 where
546 Run: 'static,
547 {
548 let session_connection = self.connection.clone();
549 self.send_fork_session(false).map(move |response| {
550 let session = ActiveSession {
551 session_id: response.session_id.clone(),
552 connection: session_connection,
553 };
554 Ok(SessionSetup { session, response })
555 })
556 }
557
558 /// Fork a protocol v2 session through a proxy and forward its response.
559 ///
560 /// The downstream request is ordered and inherits cancellation from the
561 /// upstream request. On success, this helper obtains the new session ID
562 /// from the response, installs session routing before later inbound traffic
563 /// is processed, forwards the complete response, and spawns `op` with an
564 /// [`SessionSetup`] containing the fork's command handle and response.
565 /// Inbound updates and interactive requests remain independent connection
566 /// traffic.
567 ///
568 /// The callback runs outside the ordered response barrier, so it may wait
569 /// for later connection traffic without deadlocking the dispatch loop.
570 pub fn on_proxy_session_start<F, Fut>(
571 self,
572 responder: Responder<v2::ForkSessionResponse, V2>,
573 op: F,
574 ) -> Result<(), crate::Error>
575 where
576 Counterpart: HasPeer<Client>,
577 Run: 'static,
578 F: FnOnce(SessionSetup<Counterpart, v2::ForkSessionResponse>) -> Fut + Send + 'static,
579 Fut: Future<Output = Result<(), crate::Error>> + Send,
580 {
581 let session_connection = self.connection.clone();
582 self.send_fork_session(true)
583 .forward_cancellation_from(responder.cancellation())
584 .on_receiving_ok_result(responder, async move |response, responder| {
585 let session_id = response.session_id.clone();
586 let raw_connection = session_connection.raw_connection();
587 let route = match raw_connection.add_dynamic_handler(ProxySessionMessages::new(
588 crate::v1::schema::SessionId::from(session_id.0.clone()),
589 )) {
590 Ok(route) => route,
591 Err(error) => return responder.respond_with_error(error),
592 };
593
594 let setup = SessionSetup {
595 session: ActiveSession {
596 session_id,
597 connection: session_connection.clone(),
598 },
599 response: response.clone(),
600 };
601 responder.respond(response)?;
602 route.detach();
603 raw_connection.spawn(async move { op(setup).await })
604 })
605 }
606}
607
608/// Builder for a draft protocol v2 `session/resume` request.
609///
610/// Replay updates arrive before the resume response. Direct clients must
611/// register typed [`v2::schema::UpdateSessionNotification`](v2::UpdateSessionNotification) and interactive request
612/// handlers on [`v2::Builder`](crate::v2::Builder) before connecting. Proxies should use
613/// [`Self::on_proxy_session_start`], which makes downstream session routing
614/// ready before publishing the resume request.
615///
616/// With both the `unstable_protocol_v2` and `unstable_mcp_over_acp` features,
617/// `with_mcp_server` attaches an MCP server to the resumed session and makes it
618/// ready before the agent can send replay or its response.
619#[must_use = "call `start_session` or `on_proxy_session_start` to send the `session/resume` request"]
620#[derive(Debug)]
621pub struct ResumeSessionBuilder<Counterpart, Run = NullRun>
622where
623 Counterpart: HasPeer<Agent>,
624 Run: RunWithConnectionTo<Counterpart>,
625{
626 connection: ConnectionTo<Counterpart>,
627 request: v2::ResumeSessionRequest,
628 dynamic_handler_registrations: Vec<DynamicHandlerGuard<Counterpart>>,
629 run: Run,
630}
631
632impl<Counterpart> ResumeSessionBuilder<Counterpart, NullRun>
633where
634 Counterpart: HasPeer<Agent>,
635{
636 fn new(connection: &ConnectionTo<Counterpart>, request: v2::ResumeSessionRequest) -> Self {
637 Self {
638 connection: connection.clone(),
639 request,
640 dynamic_handler_registrations: Vec::new(),
641 run: NullRun,
642 }
643 }
644}
645
646impl<Counterpart, Run> ResumeSessionBuilder<Counterpart, Run>
647where
648 Counterpart: HasPeer<Agent>,
649 Run: RunWithConnectionTo<Counterpart>,
650{
651 /// Attach an MCP server to this resumed protocol v2 session.
652 ///
653 /// This method is available when both `unstable_protocol_v2` and
654 /// `unstable_mcp_over_acp` are enabled. MCP routes are installed and their
655 /// runner tasks receive an initial poll before `session/resume` is
656 /// published, allowing the agent to use the server during replay and
657 /// session setup. A successful attachment remains active for the lifetime
658 /// of the connection.
659 ///
660 /// The declaration is an `acp` transport (`McpServer::Acp`). The agent must
661 /// advertise `session.mcp.acp`, or a compatibility proxy such as
662 /// `McpOverAcpPolyfill` must sit in front of it; otherwise a conforming
663 /// agent rejects the setup request with `-32602`. This method does not
664 /// check the agent's capabilities.
665 #[cfg(feature = "unstable_mcp_over_acp")]
666 pub fn with_mcp_server<McpRun>(
667 mut self,
668 mcp_server: McpServer<Counterpart, McpRun>,
669 ) -> Result<ResumeSessionBuilder<Counterpart, ChainRun<Run, McpRun>>, crate::Error>
670 where
671 McpRun: RunWithConnectionTo<Counterpart>,
672 {
673 let (handler, mcp_run) = mcp_server.into_v2_handler_and_runner();
674 self.dynamic_handler_registrations
675 .push(handler.into_dynamic_handler(&mut self.request.mcp_servers, &self.connection)?);
676 Ok(ResumeSessionBuilder {
677 connection: self.connection,
678 request: self.request,
679 dynamic_handler_registrations: self.dynamic_handler_registrations,
680 run: ChainRun::new(self.run, mcp_run),
681 })
682 }
683
684 fn send_resume_session(self, ordered: bool) -> SentRequest<v2::ResumeSessionResponse, V2>
685 where
686 Run: 'static,
687 {
688 let Self {
689 connection,
690 request,
691 dynamic_handler_registrations,
692 run,
693 } = self;
694 send_session_setup(
695 connection,
696 request,
697 dynamic_handler_registrations,
698 run,
699 ordered,
700 )
701 }
702
703 /// Send `session/resume` and return its independently consumable request.
704 ///
705 /// The successful result contains both a cloneable command handle and the
706 /// complete [`v2::schema::ResumeSessionResponse`](v2::ResumeSessionResponse). Consume the returned request
707 /// with [`SentRequest::block_task`], [`SentRequest::on_receiving_result`],
708 /// or another explicit [`SentRequest`] completion mode.
709 ///
710 /// Replay is delivered through the typed connection handlers before the
711 /// response. Attached MCP routes and runner tasks are ready before the
712 /// request is published. A valid success response promotes them to the
713 /// connection lifetime independently from how this request handle is
714 /// consumed; setup errors clean up the pending attachment.
715 pub fn start_session(
716 self,
717 ) -> SentRequest<SessionSetup<Counterpart, v2::ResumeSessionResponse>, V2>
718 where
719 Run: 'static,
720 {
721 let session_id = self.request.session_id.clone();
722 let session_connection = self.connection.clone();
723 self.send_resume_session(false).map(move |response| {
724 let session = ActiveSession {
725 session_id,
726 connection: session_connection,
727 };
728 Ok(SessionSetup { session, response })
729 })
730 }
731
732 /// Resume a protocol v2 session through a proxy and forward its response.
733 ///
734 /// The session ID is known before the request, so this helper installs and
735 /// acknowledges downstream session routing before publishing the ordered
736 /// `session/resume` request. Replay can therefore be forwarded before the
737 /// response as required by the protocol. The downstream request inherits
738 /// cancellation from the upstream request.
739 ///
740 /// On success, the helper forwards the complete response and spawns `op`
741 /// with an [`SessionSetup`] containing the command-only session handle
742 /// plus that response. The callback runs outside the ordered response
743 /// barrier, so it may wait for later connection traffic without
744 /// deadlocking the dispatch loop.
745 pub fn on_proxy_session_start<F, Fut>(
746 mut self,
747 responder: Responder<v2::ResumeSessionResponse, V2>,
748 op: F,
749 ) -> Result<(), crate::Error>
750 where
751 Counterpart: HasPeer<Client>,
752 Run: 'static,
753 F: FnOnce(SessionSetup<Counterpart, v2::ResumeSessionResponse>) -> Fut + Send + 'static,
754 Fut: Future<Output = Result<(), crate::Error>> + Send,
755 {
756 let session_id = self.request.session_id.clone();
757 let session_connection = self.connection.clone();
758 self.dynamic_handler_registrations.push(
759 session_connection
760 .raw_connection()
761 .add_dynamic_handler(ProxySessionMessages::new(
762 crate::v1::schema::SessionId::from(session_id.0.clone()),
763 ))?,
764 );
765
766 self.send_resume_session(true)
767 .forward_cancellation_from(responder.cancellation())
768 .on_receiving_ok_result(responder, async move |response, responder| {
769 let setup = SessionSetup {
770 session: ActiveSession {
771 session_id,
772 connection: session_connection.clone(),
773 },
774 response: response.clone(),
775 };
776 responder.respond(response)?;
777 session_connection
778 .raw_connection()
779 .spawn(async move { op(setup).await })
780 })
781 }
782}
783
784/// A protocol v2 session and the response of the request that set it up.
785///
786/// Every setup method returns one: [`SessionBuilder::start_session`]
787/// (`session/new`), [`ResumeSessionBuilder::start_session`]
788/// (`session/resume`) and, with `unstable_session_fork`,
789/// `ForkSessionBuilder::start_session` (`session/fork`), and their
790/// `on_proxy_session_start` callbacks receive one too. The response is the
791/// complete, operation-specific setup response; it is kept next to the
792/// session rather than inside [`ActiveSession`], so the setup response lives
793/// in the same place for every setup method.
794///
795/// New fields may be added, so destructure with `..`:
796///
797/// ```ignore
798/// let SessionSetup { session, response, .. } = setup;
799/// ```
800#[non_exhaustive]
801#[derive(Debug)]
802pub struct SessionSetup<Link, Response>
803where
804 Link: HasPeer<Agent>,
805{
806 /// The command handle for the session.
807 pub session: ActiveSession<Link>,
808 /// The complete response of the request that set the session up.
809 pub response: Response,
810}
811
812/// Cloneable command handle for a draft protocol v2 session.
813///
814/// Inbound protocol traffic is intentionally not owned by this value. Receive
815/// authoritative [`v2::schema::UpdateSessionNotification`](v2::UpdateSessionNotification) values and interactive
816/// requests such as [`v2::schema::RequestPermissionRequest`](v2::RequestPermissionRequest) through typed handlers
817/// installed on [`v2::Builder`](crate::v2::Builder).
818#[derive(Debug, Clone)]
819pub struct ActiveSession<Link>
820where
821 Link: HasPeer<Agent>,
822{
823 session_id: v2::SessionId,
824 connection: ConnectionTo<Link>,
825}
826
827impl<Link> ActiveSession<Link>
828where
829 Link: HasPeer<Agent>,
830{
831 /// Access the session ID.
832 pub fn session_id(&self) -> &v2::SessionId {
833 &self.session_id
834 }
835
836 /// Access the underlying connection.
837 pub fn connection(&self) -> &ConnectionTo<Link> {
838 &self.connection
839 }
840
841 /// Submit a text prompt and return its independent acceptance request.
842 ///
843 /// A successful response only acknowledges that the agent accepted the
844 /// prompt. The accepted user message, output, state changes, and completion
845 /// arrive independently through [`v2::schema::UpdateSessionNotification`](v2::UpdateSessionNotification).
846 pub fn send_prompt(&self, prompt: impl ToString) -> SentRequest<v2::PromptResponse, V2> {
847 self.send_prompt_blocks(vec![prompt.to_string().into()])
848 }
849
850 /// Submit arbitrary prompt content and return its acceptance request.
851 ///
852 /// `prompt` must not be empty; an agent may reject `[]` with `-32602`.
853 ///
854 /// The SDK does not track foreground state or gate prompt submission
855 /// locally. A prompt may be sent in any state, including while foreground
856 /// work is active: the agent either inserts it right away or holds it and
857 /// inserts held prompts in received order, and it must not reject a prompt
858 /// only because work is active. A held prompt's response stays pending
859 /// until it is inserted. Cancel the returned [`SentRequest`] to withdraw a
860 /// held prompt; [`Self::cancel_active_work`] and [`Self::close`] also
861 /// answer prompts that were not yet inserted with `-32800`.
862 pub fn send_prompt_blocks(
863 &self,
864 prompt: Vec<v2::ContentBlock>,
865 ) -> SentRequest<v2::PromptResponse, V2> {
866 self.connection.send_request_to(
867 Agent,
868 v2::PromptRequest::new(self.session_id.clone(), prompt),
869 )
870 }
871
872 /// Ask the agent to cancel the session's current foreground work.
873 ///
874 /// This is independent from cancelling a prompt's [`SentRequest`].
875 /// `session/cancel` covers the session's foreground work and prompts
876 /// received before it that the agent has not yet inserted; those prompt
877 /// requests fail with `-32800` (request cancelled). A prompt sent after
878 /// this notification is not cancelled by it. A prompt whose user message was already
879 /// inserted still succeeds with its `messageId`. Work in the latest
880 /// `background` list is not covered and keeps running.
881 ///
882 /// If foreground work was active, cancellation completes when the agent
883 /// reports an `idle` state update with [`v2::schema::StopReason::Cancelled`](v2::StopReason::Cancelled); no
884 /// completion update is required when nothing was running. `cancelled` is only
885 /// reported for work the client cancelled, through this notification or
886 /// by closing the session. The client should immediately mark unfinished
887 /// tool calls for the active work as cancelled.
888 ///
889 /// The agent stops waiting for pending
890 /// [`v2::schema::RequestPermissionRequest`](v2::RequestPermissionRequest)s
891 /// of the cancelled work and should cancel each of them with
892 /// `$/cancel_request`. Answer a cancelled permission request with the
893 /// `cancelled` outcome. A client may also answer a request it knows belongs
894 /// to the cancelled work that way without waiting for `$/cancel_request`.
895 ///
896 /// See <https://agentclientprotocol.com/protocol/v2/prompt-lifecycle#cancellation>.
897 pub fn cancel_active_work(&self) -> Result<(), crate::Error> {
898 self.connection.send_notification_to(
899 Agent,
900 v2::CancelSessionNotification::new(self.session_id.clone()),
901 )
902 }
903
904 /// Set a session configuration option.
905 ///
906 /// Only send a `config_id` the agent advertised for this session, with a
907 /// value from that option. The agent rejects an unknown `config_id` or an
908 /// invalid value with `-32602` and leaves the configuration unchanged.
909 ///
910 /// The response contains the complete option set as of the response and
911 /// may report a different `currentValue` than requested; `currentValue`
912 /// is always the value the agent is using. Apply responses and
913 /// `config_option_update` notifications in the order received, each
914 /// replacing the whole list. A duplicate `config_option_update`
915 /// notification for the same change may arrive before the response. The
916 /// response is not cached on this command handle.
917 ///
918 /// See <https://agentclientprotocol.com/protocol/v2/session-config-options>.
919 pub fn set_config_option(
920 &self,
921 config_id: impl Into<v2::SessionConfigId>,
922 value: impl Into<v2::SessionConfigOptionValue>,
923 ) -> SentRequest<v2::SetSessionConfigOptionResponse, V2> {
924 self.connection.send_request_to(
925 Agent,
926 v2::SetSessionConfigOptionRequest::new(self.session_id.clone(), config_id, value),
927 )
928 }
929
930 /// Close the remote session and release its resources.
931 ///
932 /// The agent cancels the session's ongoing work as if `session/cancel` had
933 /// been sent. When that cancels active foreground work, the agent sends the
934 /// work's final updates and an `idle` state update with stop reason
935 /// `cancelled` before it responds to `session/close`. A pending prompt
936 /// whose user message closing kept from being inserted fails with `-32800`
937 /// (request cancelled); one already inserted still succeeds with its
938 /// message ID. Prompt responses need not precede the close response.
939 /// Before it responds, the agent cancels every pending permission request
940 /// and elicitation in the session with `$/cancel_request`.
941 ///
942 /// Existing clones of this local command handle are not invalidated, but
943 /// the session is no longer active: the agent rejects later commands for it
944 /// with `-32002` (resource not found).
945 ///
946 /// Closing is idempotent. An agent should answer a close of an unknown or
947 /// already-closed session with success, and an older agent may answer
948 /// `-32002` instead, so this helper resolves a `-32002` response as a
949 /// successful, empty [`v2::schema::CloseSessionResponse`](v2::CloseSessionResponse). Other errors are
950 /// returned unchanged.
951 ///
952 /// See <https://agentclientprotocol.com/protocol/v2/session-setup#closing-active-sessions>.
953 pub fn close(&self) -> SentRequest<v2::CloseSessionResponse, V2> {
954 self.connection
955 .send_request_to(Agent, v2::CloseSessionRequest::new(self.session_id.clone()))
956 .recover_error(|error| {
957 if error.code == v2::ErrorCode::ResourceNotFound {
958 Ok(v2::CloseSessionResponse::new())
959 } else {
960 Err(error)
961 }
962 })
963 }
964}