1use std::collections::HashMap;
23use std::path::{Path, PathBuf};
24use std::sync::Arc;
25use std::time::Instant;
26
27use forge_sdk::Client;
28use parking_lot::Mutex;
29use serde_json::Value;
30use tokio::sync::{mpsc, oneshot};
31use tracing::Instrument;
32
33use crate::client::{AgentEvent, SessionLaunchSettings, SpawnFailureKind};
34use crate::forge_sdk_worker;
35use crate::forge_sdk_worker::SessionId;
36use forge_primitives::{PermissionMode, PermissionOutcome, QuestionOutcome};
37
38fn spawn_failure_kind(err: &anyhow::Error) -> SpawnFailureKind {
41 match err.downcast_ref::<forge_sdk::Error>() {
42 Some(forge_sdk::Error::CwdNotFound { .. }) => SpawnFailureKind::WorkingDirMissing,
43 _ => SpawnFailureKind::Unclassified,
44 }
45}
46
47fn turn_error(session_id: String, message: String) -> AgentEvent {
51 let class = crate::translate::error_handling::classify_turn_error(&message);
52 AgentEvent::TurnError { session_id, message, class }
53}
54
55#[cfg(any(test, feature = "testing"))]
60const TESTING_STUB_CONFIG_DIR: &str = "/tmp/forge-testing-stub";
61
62pub(crate) type PendingResponses =
67 Arc<Mutex<HashMap<String, oneshot::Sender<forge_primitives::PermissionDecision>>>>;
68
69pub(crate) type PendingQuestions = Arc<Mutex<HashMap<String, oneshot::Sender<QuestionOutcome>>>>;
74
75#[derive(Clone)]
81pub struct ForgeSdkBridge {
82 inner: Arc<BridgeInner>,
83}
84
85pub(crate) struct BridgeInner {
86 client: Mutex<Option<Client>>,
89 event_tx: mpsc::UnboundedSender<AgentEvent>,
92 events_rx: Mutex<Option<mpsc::UnboundedReceiver<AgentEvent>>>,
94 pub(crate) pending: PendingResponses,
96 pub(crate) pending_questions: PendingQuestions,
98 pub(crate) session_id_slot: Arc<Mutex<String>>,
101 config_dir: PathBuf,
109 display_name: Option<String>,
115 extra_mcp_servers: Vec<(String, forge_sdk::mcp::McpServer)>,
122 env: HashMap<String, String>,
129 context_probes_in_flight: Mutex<HashMap<String, Instant>>,
144}
145
146struct ProbeInFlight {
157 inner: Arc<BridgeInner>,
158 session_id: String,
159 taken_at: Instant,
160}
161
162impl Drop for ProbeInFlight {
163 fn drop(&mut self) {
164 let mut marks = self.inner.context_probes_in_flight.lock();
165 if marks.get(&self.session_id) == Some(&self.taken_at) {
166 marks.remove(&self.session_id);
167 }
168 }
169}
170
171impl ForgeSdkBridge {
172 pub(crate) fn new(
182 config_dir: PathBuf,
183 display_name: Option<String>,
184 extra_mcp_servers: Vec<(String, forge_sdk::mcp::McpServer)>,
185 env: HashMap<String, String>,
186 ) -> Self {
187 let (event_tx, events_rx) = mpsc::unbounded_channel();
188 Self {
189 inner: Arc::new(BridgeInner {
190 client: Mutex::new(None),
191 event_tx,
192 events_rx: Mutex::new(Some(events_rx)),
193 pending: Arc::new(Mutex::new(HashMap::new())),
194 pending_questions: Arc::new(Mutex::new(HashMap::new())),
195 session_id_slot: Arc::new(Mutex::new(String::new())),
196 config_dir,
197 display_name,
198 extra_mcp_servers,
199 env,
200 context_probes_in_flight: Mutex::new(HashMap::new()),
201 }),
202 }
203 }
204
205 pub(crate) fn env(&self) -> HashMap<String, String> {
211 self.inner.env.clone()
212 }
213
214 pub(crate) fn extra_mcp_servers(&self) -> Vec<(String, forge_sdk::mcp::McpServer)> {
220 self.inner.extra_mcp_servers.clone()
221 }
222
223 pub(crate) fn event_tx(&self) -> &mpsc::UnboundedSender<AgentEvent> {
224 &self.inner.event_tx
225 }
226
227 pub(crate) fn inner_pending(&self) -> &PendingResponses {
228 &self.inner.pending
229 }
230
231 pub(crate) fn inner_pending_questions(&self) -> &PendingQuestions {
232 &self.inner.pending_questions
233 }
234
235 pub(crate) fn session_id_slot_arc(&self) -> &Arc<Mutex<String>> {
236 &self.inner.session_id_slot
237 }
238
239 fn client(&self) -> Option<Client> {
240 self.inner.client.lock().clone()
241 }
242
243 pub(crate) fn set_client(&self, client: Client) {
244 *self.inner.client.lock() = Some(client);
245 }
246
247 pub(crate) fn clear_client(&self) -> Option<Client> {
248 for (_, tx) in self.inner.pending.lock().drain() {
256 let _ = tx.send(forge_primitives::PermissionDecision::deny("session replaced"));
257 }
258 for (_, tx) in self.inner.pending_questions.lock().drain() {
259 let _ = tx.send(forge_primitives::QuestionOutcome::Cancelled);
260 }
261 self.inner.client.lock().take()
262 }
263
264 fn emit_mcp_error_or_log(
273 event_tx: &mpsc::UnboundedSender<AgentEvent>,
274 session_id: String,
275 operation: &'static str,
276 server_name: Option<String>,
277 error_msg: String,
278 ) {
279 let session_id_for_log = session_id.clone();
280 let server_name_for_log = server_name.clone();
281 let error_msg_for_log = error_msg.clone();
282 let event = AgentEvent::McpOperationError {
283 session_id,
284 error: forge_primitives::McpOperationError {
285 operation: operation.to_owned(),
286 server_name,
287 message: error_msg,
288 },
289 };
290 if event_tx.send(event).is_err() {
291 tracing::warn!(
292 target: crate::logging::targets::BRIDGE_LIFECYCLE,
293 session_id = %session_id_for_log,
294 operation,
295 server_name = ?server_name_for_log,
296 error_msg = %error_msg_for_log,
297 "event channel closed; McpOperationError dropped",
298 );
299 }
300 }
301
302 fn dispatch<F, Fut>(&self, label: &'static str, f: F) -> anyhow::Result<()>
304 where
305 F: FnOnce(Client) -> Fut + Send + 'static,
306 Fut: std::future::Future<Output = anyhow::Result<()>> + Send + 'static,
307 {
308 self.dispatch_with_failure(label, f, |_| None)
309 }
310
311 fn dispatch_with_failure<F, Fut, T>(
318 &self,
319 label: &'static str,
320 f: F,
321 on_failure: T,
322 ) -> anyhow::Result<()>
323 where
324 F: FnOnce(Client) -> Fut + Send + 'static,
325 Fut: std::future::Future<Output = anyhow::Result<()>> + Send + 'static,
326 T: FnOnce(&anyhow::Error) -> Option<AgentEvent> + Send + 'static,
327 {
328 let Some(client) = self.client() else {
329 let err = anyhow::anyhow!("forge-sdk bridge: {label} called before active session");
330 if let Some(event) = on_failure(&err)
333 && self.inner.event_tx.send(event).is_err()
334 {
335 tracing::warn!(
336 target: crate::logging::targets::BRIDGE_LIFECYCLE,
337 label,
338 "event channel closed; typed dispatch failure dropped",
339 );
340 }
341 return Err(err);
342 };
343 let event_tx = self.inner.event_tx.clone();
344 let span = tracing::info_span!("bridge_dispatch", label);
345 tokio::spawn(
346 async move {
347 if let Err(err) = f(client).await {
348 tracing::warn!(
349 target: crate::logging::targets::BRIDGE_LIFECYCLE,
350 label,
351 error = %err,
352 "forge-sdk bridge: dispatch failed",
353 );
354 if let Some(event) = on_failure(&err)
355 && event_tx.send(event).is_err()
356 {
357 tracing::warn!(
358 target: crate::logging::targets::BRIDGE_LIFECYCLE,
359 label,
360 "event channel closed; typed dispatch failure dropped",
361 );
362 }
363 }
364 }
365 .instrument(span),
366 );
367 Ok(())
368 }
369}
370
371#[cfg(any(test, feature = "testing"))]
372impl Default for ForgeSdkBridge {
373 fn default() -> Self {
374 Self::new(PathBuf::from(TESTING_STUB_CONFIG_DIR), None, Vec::new(), HashMap::new())
375 }
376}
377
378#[allow(clippy::unused_self, clippy::needless_pass_by_value)]
384impl ForgeSdkBridge {
385 pub(crate) fn take_events(&self) -> Option<mpsc::UnboundedReceiver<AgentEvent>> {
386 self.inner.events_rx.lock().take()
387 }
388
389 pub(crate) fn prompt_text(
390 &self,
391 session_id: String,
392 text: String,
393 uuid: String,
394 ) -> anyhow::Result<()> {
395 self.prompt_with_images(session_id, text, Vec::new(), uuid)
396 }
397
398 pub(crate) fn prompt_with_images(
399 &self,
400 session_id: String,
401 text: String,
402 images: Vec<forge_primitives::ImageAttachment>,
403 uuid: String,
404 ) -> anyhow::Result<()> {
405 self.trace_session_id_bypass(&session_id, "prompt_with_images");
414 let mut chunks: Vec<forge_primitives::PromptChunk> = Vec::with_capacity(1 + images.len());
415 for img in images {
416 if let Err(reason) = forge_primitives::validate_image(&img.data, &img.mime_type) {
417 tracing::warn!(
418 target: crate::logging::targets::APP_INPUT,
419 "forge-sdk bridge: skipping invalid image: {reason}"
420 );
421 continue;
422 }
423 chunks.push(forge_primitives::PromptChunk {
424 kind: "image".to_owned(),
425 value: serde_json::json!({
426 "data": img.data,
427 "mime_type": img.mime_type,
428 }),
429 });
430 }
431 chunks.push(forge_primitives::PromptChunk {
432 kind: "text".to_owned(),
433 value: Value::String(text),
434 });
435 self.dispatch_with_failure(
436 "prompt",
437 move |client| async move { forge_sdk_worker::send_prompt(&client, chunks, &uuid).await },
438 move |err| Some(turn_error(session_id.clone(), err.to_string())),
439 )
440 }
441
442 pub(crate) fn cancel_queued(&self, session_id: String, uuid: String) -> anyhow::Result<()> {
447 if !self.check_session_id(&session_id, "cancel_queued") {
448 return Ok(());
449 }
450 let event_tx = self.inner.event_tx.clone();
451 let err_session_id = session_id.clone();
452 let answered = uuid.clone();
453 self.dispatch_with_failure(
454 "cancel_queued",
455 move |client| async move {
456 let cancelled = tokio::time::timeout(
457 Self::CONTROL_RESPONSE_TIMEOUT,
458 client.cancel_queued_message(&uuid),
459 )
460 .await
461 .map_err(|_| {
462 anyhow::anyhow!("cancel_async_message not acknowledged by the CLI")
463 })??;
464 if event_tx
465 .send(AgentEvent::PromptCancelResolved { uuid: answered, cancelled })
466 .is_err()
467 {
468 tracing::warn!(
469 target: crate::logging::targets::BRIDGE_LIFECYCLE,
470 "event channel closed; PromptCancelResolved dropped",
471 );
472 }
473 Ok(())
474 },
475 move |err| Some(turn_error(err_session_id.clone(), err.to_string())),
476 )
477 }
478
479 pub(crate) fn cancel(&self, session_id: String) -> anyhow::Result<()> {
480 if !self.check_session_id(&session_id, "cancel") {
481 return Ok(());
482 }
483 let event_tx = self.inner.event_tx.clone();
484 let err_session_id = session_id.clone();
485 self.dispatch_with_failure(
486 "cancel",
487 move |client| async move {
488 match tokio::time::timeout(Self::CONTROL_RESPONSE_TIMEOUT, client.interrupt()).await
489 {
490 Ok(Ok(())) => Ok(()),
491 Ok(Err(e)) => Err(e.into()),
492 Err(_) => {
493 let message = "interrupt not acknowledged by the CLI".to_owned();
494 if event_tx.send(turn_error(session_id.clone(), message.clone())).is_err() {
495 tracing::warn!(
496 target: crate::logging::targets::BRIDGE_LIFECYCLE,
497 "event channel closed; TurnError dropped",
498 );
499 }
500 Ok(())
501 }
502 }
503 },
504 move |err| {
505 Some(turn_error(err_session_id, format!("interrupt not acknowledged: {err}")))
506 },
507 )
508 }
509
510 const CONTROL_RESPONSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
515
516 const PROBE_MARK_DEADLINE: std::time::Duration = Self::CONTROL_RESPONSE_TIMEOUT;
523
524 fn set_mode_rejection_text(err: &forge_sdk::Error) -> String {
529 if let forge_sdk::Error::MessageParse { reason, .. } = err
530 && let Some(cli_reason) = reason.strip_prefix("control failed: ")
531 {
532 return cli_reason.to_owned();
533 }
534 err.to_string()
535 }
536
537 pub(crate) fn set_mode(&self, session_id: String, mode: PermissionMode) -> anyhow::Result<()> {
538 if !self.check_session_id(&session_id, "set_mode") {
539 let _ = self.inner.event_tx.send(AgentEvent::SetModeFailed {
542 session_id,
543 mode,
544 message: "the session it was sent to is no longer active".to_owned(),
545 });
546 return Ok(());
547 }
548 let event_tx = self.inner.event_tx.clone();
549 let err_session_id = session_id.clone();
550 self.dispatch_with_failure(
551 "set_mode",
552 move |client| async move {
553 let outcome = tokio::time::timeout(
554 Self::CONTROL_RESPONSE_TIMEOUT,
555 client.set_permission_mode(mode),
556 )
557 .await;
558 let failure = match outcome {
559 Ok(Ok(())) => None,
560 Ok(Err(e)) => {
561 let text = Self::set_mode_rejection_text(&e);
562 Some(if text.trim().is_empty() {
563 "no reason given".to_owned()
564 } else {
565 text
566 })
567 }
568 Err(_) => Some("no response from the CLI".to_owned()),
569 };
570 if let Some(message) = failure {
571 if event_tx
572 .send(AgentEvent::SetModeFailed {
573 session_id: session_id.clone(),
574 mode,
575 message: message.clone(),
576 })
577 .is_err()
578 {
579 tracing::warn!(
580 target: crate::logging::targets::BRIDGE_LIFECYCLE,
581 error = %message,
582 "event channel closed; SetModeFailed dropped",
583 );
584 } else {
585 tracing::warn!(
586 target: crate::logging::targets::BRIDGE_LIFECYCLE,
587 session_id = %session_id,
588 mode = %mode.as_wire(),
589 error = %message,
590 "set_permission_mode rejected; SetModeFailed emitted",
591 );
592 }
593 }
594 Ok(())
595 },
596 move |err| {
597 Some(AgentEvent::SetModeFailed {
598 session_id: err_session_id,
599 mode,
600 message: format!("set_mode never reached the CLI: {err}"),
601 })
602 },
603 )
604 }
605
606 pub(crate) fn set_model(&self, session_id: String, model: String) -> anyhow::Result<()> {
607 if !self.check_session_id(&session_id, "set_model") {
608 let _ = self.inner.event_tx.send(AgentEvent::SetModelFailed {
612 session_id: session_id.clone(),
613 model: model.clone(),
614 message: "the session it was sent to is no longer active".to_owned(),
615 });
616 return Ok(());
617 }
618 let event_tx = self.inner.event_tx.clone();
619 let err_session_id = session_id.clone();
620 let err_model = model.clone();
621 self.dispatch_with_failure(
622 "set_model",
623 move |client| async move {
624 let outcome = tokio::time::timeout(
625 Self::CONTROL_RESPONSE_TIMEOUT,
626 client.set_model(Some(model.as_str())),
627 )
628 .await;
629 let failure = match outcome {
630 Ok(Ok(())) => None,
631 Ok(Err(e)) => {
632 let text = Self::set_mode_rejection_text(&e);
633 Some(if text.trim().is_empty() {
634 "no reason given".to_owned()
635 } else {
636 text
637 })
638 }
639 Err(_) => Some("no response from the CLI".to_owned()),
640 };
641 if let Some(message) = failure
642 && event_tx
643 .send(AgentEvent::SetModelFailed {
644 session_id: session_id.clone(),
645 model: model.clone(),
646 message: message.clone(),
647 })
648 .is_err()
649 {
650 tracing::warn!(
651 target: crate::logging::targets::BRIDGE_LIFECYCLE,
652 error = %message,
653 "event channel closed; SetModelFailed dropped",
654 );
655 }
656 Ok(())
657 },
658 move |err| {
659 Some(AgentEvent::SetModelFailed {
660 session_id: err_session_id,
661 model: err_model,
662 message: format!("set_model never reached the CLI: {err}"),
663 })
664 },
665 )
666 }
667
668 fn check_session_id(&self, session_id: &str, label: &'static str) -> bool {
679 if session_id.is_empty() {
691 let current = self.inner.session_id_slot.lock().clone();
692 tracing::error!(
693 target: crate::logging::targets::BRIDGE_LIFECYCLE,
694 event_name = "dispatch_without_session_id",
695 label,
696 current_session_id = %current,
697 "dropping a dispatch that carries no session id"
698 );
699 return false;
700 }
701 let current = self.inner.session_id_slot.lock().clone();
702 if current == session_id {
703 return true;
704 }
705 tracing::warn!(
706 target: crate::logging::targets::BRIDGE_LIFECYCLE,
707 event_name = "stale_session_dispatch",
708 label,
709 current_session_id = %current,
710 requested_session_id = %session_id,
711 "dropping dispatch for stale session id"
712 );
713 false
714 }
715
716 fn trace_session_id_bypass(&self, session_id: &str, label: &'static str) {
722 let current = self.inner.session_id_slot.lock().clone();
723 if current == session_id {
724 return;
725 }
726 if session_id.is_empty() {
727 tracing::warn!(
728 target: crate::logging::targets::BRIDGE_LIFECYCLE,
729 event_name = "bypass_without_session_id",
730 label,
731 current_session_id = %current,
732 "passing through a dispatch that carries no session id (intentional bypass)",
733 );
734 return;
735 }
736 tracing::trace!(
737 target: crate::logging::targets::BRIDGE_LIFECYCLE,
738 event_name = "stale_session_bypass",
739 label,
740 current_session_id = %current,
741 requested_session_id = %session_id,
742 "passing through dispatch despite stale session id (intentional bypass)",
743 );
744 }
745
746 pub(crate) fn get_context_usage(&self, session_id: String) -> anyhow::Result<()> {
747 let taken_at = Instant::now();
748 {
749 let mut marks = self.inner.context_probes_in_flight.lock();
750 if let Some(out_since) = marks.get(&session_id) {
751 let age = taken_at.duration_since(*out_since);
752 if age < Self::PROBE_MARK_DEADLINE {
753 tracing::debug!(
754 target: crate::logging::targets::BRIDGE_LIFECYCLE,
755 event_name = "context_usage_probe_coalesced",
756 session_id = %session_id,
757 "context usage probe already in flight; this ask is coalesced",
758 );
759 return Ok(());
760 }
761 tracing::warn!(
764 target: crate::logging::targets::BRIDGE_LIFECYCLE,
765 event_name = "context_usage_probe_mark_reclaimed",
766 session_id = %session_id,
767 age_secs = age.as_secs(),
768 "a context usage probe outlived its deadline; starting a fresh probe",
769 );
770 }
771 marks.insert(session_id.clone(), taken_at);
772 }
773 let probe = ProbeInFlight {
774 inner: Arc::clone(&self.inner),
775 session_id: session_id.clone(),
776 taken_at,
777 };
778 let event_tx = self.inner.event_tx.clone();
779 self.dispatch("get_context_usage", move |client| async move {
780 let usage = match tokio::time::timeout(
788 Self::CONTROL_RESPONSE_TIMEOUT,
789 client.get_context_usage(),
790 )
791 .await
792 {
793 Ok(Ok(usage)) => usage,
794 Ok(Err(e)) => return Err(e.into()),
795 Err(_) => {
796 tracing::warn!(
800 target: crate::logging::targets::BRIDGE_LIFECYCLE,
801 session_id = %session_id,
802 "context usage probe timed out; skipping this poll",
803 );
804 return Ok(());
805 }
806 };
807 drop(probe);
810 let percentage = forge_sdk_worker::reported_percentage(usage.percentage);
811 let max_tokens = Some(usage.raw_max_tokens);
817 let effective_max_tokens = Some(usage.max_tokens);
818 let total_tokens = Some(usage.total_tokens);
819 if event_tx
820 .send(AgentEvent::ContextUsage {
821 session_id,
822 percentage,
823 max_tokens,
824 total_tokens,
825 effective_max_tokens,
826 })
827 .is_err()
828 {
829 tracing::warn!(
830 target: crate::logging::targets::BRIDGE_LIFECYCLE,
831 "event channel closed; ContextUsage dropped",
832 );
833 }
834 Ok(())
835 })
836 }
837
838 pub(crate) fn get_mcp_snapshot(&self, session_id: String) -> anyhow::Result<()> {
839 let event_tx = self.inner.event_tx.clone();
840 let err_session_id = session_id.clone();
841 self.dispatch_with_failure(
842 "get_mcp_snapshot",
843 move |client| async move {
844 let outcome =
845 tokio::time::timeout(Self::CONTROL_RESPONSE_TIMEOUT, client.mcp_status()).await;
846 let response = match outcome {
847 Ok(Err(e)) => return Err(e.into()),
848 Ok(Ok(result)) => result,
849 Err(_) => {
850 Self::emit_mcp_error_or_log(
851 &event_tx,
852 session_id,
853 "status",
854 None,
855 "no response from the CLI".to_owned(),
856 );
857 return Ok(());
858 }
859 };
860 if event_tx
861 .send(AgentEvent::McpSnapshot {
862 session_id,
863 servers: response.mcp_servers,
864 error: None,
865 })
866 .is_err()
867 {
868 tracing::warn!(
869 target: crate::logging::targets::BRIDGE_LIFECYCLE,
870 "event channel closed; McpSnapshot dropped",
871 );
872 }
873 Ok(())
874 },
875 move |err| {
876 Some(AgentEvent::McpOperationError {
877 session_id: err_session_id,
878 error: forge_primitives::McpOperationError {
879 operation: "status".to_owned(),
880 server_name: None,
881 message: err.to_string(),
882 },
883 })
884 },
885 )
886 }
887
888 fn decode_or_report(
896 &self,
897 session_id: Option<&str>,
898 launch_settings: serde_json::Value,
899 ) -> anyhow::Result<SessionLaunchSettings> {
900 crate::agent::decode_launch_settings(launch_settings).map_err(|error| {
901 tracing::error!(
902 target: crate::logging::targets::BRIDGE_LIFECYCLE,
903 event_name = "launch_settings_decode_failed",
904 session_id = session_id.unwrap_or(""),
905 "failed to decode launch_settings; the spawn is refused rather than started \
906 with none of them",
907 );
908 let msg = format!("forge-sdk session spawn refused: {error}");
909 let kind = spawn_failure_kind(&error);
910 if self
911 .event_tx()
912 .send(AgentEvent::ConnectionFailed { message: msg.clone(), kind })
913 .is_err()
914 {
915 tracing::warn!(
916 target: crate::logging::targets::BRIDGE_LIFECYCLE,
917 error = %msg,
918 "event channel closed; ConnectionFailed dropped",
919 );
920 }
921 error
922 })
923 }
924
925 pub(crate) fn new_session(
928 &self,
929 session_id: Option<String>,
930 cwd: String,
931 launch_settings: serde_json::Value,
932 ) -> anyhow::Result<()> {
933 let launch_settings = self.decode_or_report(session_id.as_deref(), launch_settings)?;
934 let bridge = self.clone();
935 let span = tracing::info_span!("bridge_new_session", cwd = %cwd);
936 tokio::spawn(
937 async move {
938 let id = match session_id.as_deref() {
939 Some(id) => SessionId::Create(id),
940 None => SessionId::Unassigned,
941 };
942 if let Err(err) =
943 forge_sdk_worker::spawn_session(&bridge, &cwd, id, &launch_settings).await
944 {
945 let kind = spawn_failure_kind(&err);
946 let msg = format!("forge-sdk session spawn failed: {err}");
947 if bridge
948 .event_tx()
949 .send(AgentEvent::ConnectionFailed { message: msg.clone(), kind })
950 .is_err()
951 {
952 tracing::warn!(
953 target: crate::logging::targets::BRIDGE_LIFECYCLE,
954 error = %msg,
955 "event channel closed; ConnectionFailed dropped",
956 );
957 }
958 }
959 }
960 .instrument(span),
961 );
962 Ok(())
963 }
964
965 pub(crate) fn resume_session(
966 &self,
967 session_id: String,
968 cwd: String,
969 launch_settings: serde_json::Value,
970 ) -> anyhow::Result<()> {
971 let launch_settings = self.decode_or_report(Some(&session_id), launch_settings)?;
972 let bridge = self.clone();
973 let span = tracing::info_span!(
974 "bridge_resume_session",
975 session_id = %session_id,
976 cwd = %cwd,
977 );
978 tokio::spawn(
979 async move {
980 if let Err(err) = forge_sdk_worker::spawn_session(
981 &bridge,
982 &cwd,
983 SessionId::Resume(&session_id),
984 &launch_settings,
985 )
986 .await
987 {
988 let kind = spawn_failure_kind(&err);
989 let msg = format!("forge-sdk session resume failed: {err}");
990 if bridge
991 .event_tx()
992 .send(AgentEvent::ConnectionFailed { message: msg.clone(), kind })
993 .is_err()
994 {
995 tracing::warn!(
996 target: crate::logging::targets::BRIDGE_LIFECYCLE,
997 error = %msg,
998 "event channel closed; ConnectionFailed dropped",
999 );
1000 }
1001 }
1002 }
1003 .instrument(span),
1004 );
1005 Ok(())
1006 }
1007
1008 pub(crate) fn resume_or_new_session(
1030 &self,
1031 session_id: String,
1032 cwd: String,
1033 launch_settings: serde_json::Value,
1034 ) -> anyhow::Result<()> {
1035 let launch_settings = self.decode_or_report(Some(&session_id), launch_settings)?;
1036 let bridge = self.clone();
1037 let span = tracing::info_span!(
1038 "bridge_resume_or_new_session",
1039 session_id = %session_id,
1040 cwd = %cwd,
1041 );
1042 tokio::spawn(async move {
1043 let Err(resume_err) = forge_sdk_worker::spawn_session(
1044 &bridge,
1045 &cwd,
1046 SessionId::Resume(&session_id),
1047 &launch_settings,
1048 )
1049 .await
1050 else {
1051 return;
1052 };
1053 tracing::warn!(
1054 target: crate::logging::targets::BRIDGE_LIFECYCLE,
1055 error = %resume_err,
1056 session_id = %session_id,
1057 "session resume failed; starting a new session under the same id",
1058 );
1059 let Err(create_err) = forge_sdk_worker::spawn_session(
1060 &bridge,
1061 &cwd,
1062 SessionId::Create(&session_id),
1063 &launch_settings,
1064 )
1065 .await
1066 else {
1067 return;
1068 };
1069 let fresh = uuid::Uuid::new_v4().to_string();
1073 tracing::warn!(
1074 target: crate::logging::targets::BRIDGE_LIFECYCLE,
1075 error = %create_err,
1076 session_id = %session_id,
1077 fresh_session_id = %fresh,
1078 "session could neither be resumed nor created under its id; starting under a \
1079 new one",
1080 );
1081 if let Err(new_err) = forge_sdk_worker::spawn_session(
1082 &bridge,
1083 &cwd,
1084 SessionId::Create(&fresh),
1085 &launch_settings,
1086 )
1087 .await
1088 {
1089 let msg = format!(
1090 "forge-sdk session spawn failed after the resume fallbacks (resume err: {resume_err}; same-id err: {create_err}; fresh err: {new_err})",
1091 );
1092 if bridge
1093 .event_tx()
1094 .send(AgentEvent::ConnectionFailed {
1095 message: msg.clone(),
1096 kind: SpawnFailureKind::Unclassified,
1099 })
1100 .is_err()
1101 {
1102 tracing::warn!(
1103 target: crate::logging::targets::BRIDGE_LIFECYCLE,
1104 error = %msg,
1105 "event channel closed; ConnectionFailed dropped",
1106 );
1107 }
1108 }
1109 }.instrument(span));
1110 Ok(())
1111 }
1112
1113 pub(crate) fn permission_response(
1121 &self,
1122 _session_id: String,
1123 tool_call_id: String,
1124 outcome: PermissionOutcome,
1125 ) -> anyhow::Result<()> {
1126 forge_sdk_worker::deliver_permission_response(&self.inner.pending, &tool_call_id, outcome);
1127 Ok(())
1128 }
1129
1130 pub(crate) fn question_response(
1131 &self,
1132 _session_id: String,
1133 tool_call_id: String,
1134 outcome: QuestionOutcome,
1135 ) -> anyhow::Result<()> {
1136 forge_sdk_worker::deliver_question_response(
1137 &self.inner.pending_questions,
1138 &tool_call_id,
1139 outcome,
1140 );
1141 Ok(())
1142 }
1143
1144 pub(crate) fn config_dir(&self) -> PathBuf {
1150 self.inner.config_dir.clone()
1151 }
1152
1153 pub(crate) fn display_name(&self) -> Option<String> {
1158 self.inner.display_name.clone()
1159 }
1160
1161 pub(crate) fn claude_pid(&self) -> Option<u32> {
1168 self.client().and_then(|c| c.claude_pid())
1169 }
1170
1171 pub(crate) fn project_memory_path(&self, cwd: &Path) -> PathBuf {
1172 crate::userdata::memory::project_memory_path(&self.inner.config_dir, cwd)
1173 }
1174
1175 pub(crate) fn settings_documents(
1176 &self,
1177 cwd: Option<&Path>,
1178 ) -> crate::userdata::settings::SettingsDocuments {
1179 crate::userdata::settings::settings_documents(&self.inner.config_dir, cwd)
1180 }
1181}
1182
1183#[cfg(test)]
1184mod tests {
1185 use super::*;
1186
1187 #[test]
1192 fn a_missing_working_directory_is_named_on_the_failure() {
1193 let missing = anyhow::Error::new(forge_sdk::Error::CwdNotFound { path: "/gone".into() });
1194 assert_eq!(
1195 spawn_failure_kind(&missing),
1196 SpawnFailureKind::WorkingDirMissing,
1197 "the SDK error has to reach the kind through the anyhow wrapper \
1198 spawn_session hands back",
1199 );
1200
1201 let unnamed = anyhow::Error::new(forge_sdk::Error::CliNotFound { binary: "claude".into() });
1202 assert_eq!(
1203 spawn_failure_kind(&unnamed),
1204 SpawnFailureKind::Unclassified,
1205 "an unnamed failure leaves the message heuristics in charge",
1206 );
1207 }
1208
1209 #[test]
1213 fn a_turn_error_carries_the_class_of_its_own_text() {
1214 let auth = turn_error("s1".to_owned(), "authentication failed: please log in".to_owned());
1215 let AgentEvent::TurnError { class, .. } = &auth else {
1216 panic!("a built turn error is a TurnError, got {auth:?}")
1217 };
1218 assert_eq!(
1219 class,
1220 &forge_primitives::TurnErrorClass::AuthRequired,
1221 "an auth-shaped failure carries the class, not a value a reader has to re-derive",
1222 );
1223
1224 let plain = turn_error("s1".to_owned(), "stdin write failed".to_owned());
1225 let AgentEvent::TurnError { class, .. } = &plain else {
1226 panic!("a built turn error is a TurnError, got {plain:?}")
1227 };
1228 assert_eq!(
1229 class,
1230 &forge_primitives::TurnErrorClass::Other,
1231 "a failure the classifier does not know is Other rather than unset",
1232 );
1233 }
1234
1235 fn bind_session_slot(bridge: &ForgeSdkBridge, session_id: &str) {
1241 bridge.session_id_slot_arc().lock().clone_from(&session_id.to_owned());
1242 }
1243
1244 #[test]
1249 fn check_session_id_matches_by_id_only() {
1250 let bridge = test_bridge();
1251 bind_session_slot(&bridge, "session-1");
1252
1253 assert!(bridge.check_session_id("session-1", "cancel"), "the session's own id passes");
1254 assert!(!bridge.check_session_id("session-2", "cancel"), "another session's id is refused");
1255 assert!(
1256 !bridge.check_session_id("", "cancel"),
1257 "a request carrying no id is refused, not matched to anything",
1258 );
1259 }
1260
1261 #[test]
1267 fn an_unbound_slot_refuses_rather_than_matching_anything() {
1268 let bridge = test_bridge();
1269
1270 assert!(
1271 !bridge.check_session_id("session-1", "cancel"),
1272 "an unbound slot does not accept a request it cannot check",
1273 );
1274 assert!(
1275 !bridge.check_session_id("", "cancel"),
1276 "nor an id-less request, which two empty ids would otherwise match",
1277 );
1278 }
1279
1280 fn test_bridge() -> ForgeSdkBridge {
1281 ForgeSdkBridge::new(
1282 PathBuf::from(TESTING_STUB_CONFIG_DIR),
1283 None,
1284 Vec::new(),
1285 HashMap::new(),
1286 )
1287 }
1288
1289 #[test]
1290 fn take_events_returns_some_once_then_none() {
1291 let bridge = test_bridge();
1292 assert!(bridge.take_events().is_some());
1293 assert!(bridge.take_events().is_none());
1294 }
1295
1296 #[test]
1297 fn clear_client_drains_pending_permission_map() {
1298 let bridge = test_bridge();
1299 let (tx, rx) = tokio::sync::oneshot::channel::<forge_primitives::PermissionDecision>();
1300 bridge.inner.pending.lock().insert("tool-id-1".to_owned(), tx);
1301
1302 bridge.clear_client();
1303
1304 assert!(bridge.inner.pending.lock().is_empty());
1305 let decision = rx.blocking_recv().expect("oneshot resolved by clear_client drain");
1306 assert!(!decision.is_allow(), "drain must resolve with deny");
1307 }
1308
1309 #[test]
1310 fn clear_client_drains_pending_question_map() {
1311 let bridge = test_bridge();
1312 let (tx, rx) = tokio::sync::oneshot::channel::<forge_primitives::QuestionOutcome>();
1313 bridge.inner.pending_questions.lock().insert("tool-id-q1".to_owned(), tx);
1314
1315 bridge.clear_client();
1316
1317 assert!(bridge.inner.pending_questions.lock().is_empty());
1318 let outcome = rx.blocking_recv().expect("oneshot resolved by clear_client drain");
1319 assert!(matches!(outcome, forge_primitives::QuestionOutcome::Cancelled));
1320 }
1321
1322 #[test]
1323 fn dispatch_without_client_returns_error() {
1324 let bridge = test_bridge();
1325 bind_session_slot(&bridge, "session-1");
1326 let err = bridge.cancel("session-1".to_owned()).unwrap_err();
1327 assert!(err.to_string().contains("before active session"));
1328 }
1329
1330 #[tokio::test]
1336 async fn dispatch_without_client_emits_the_typed_event() {
1337 let bridge = test_bridge();
1338 let mut events = bridge.take_events().expect("fresh bridge yields its events receiver");
1339
1340 let err = bridge
1341 .dispatch_with_failure(
1342 "prompt",
1343 |_client| async { Ok::<(), anyhow::Error>(()) },
1344 |err| Some(turn_error("s1".to_owned(), err.to_string())),
1345 )
1346 .expect_err("no client, dispatch refused");
1347 assert!(err.to_string().contains("before active session"));
1348
1349 match tokio::time::timeout(std::time::Duration::from_secs(2), events.recv()).await {
1350 Ok(Some(AgentEvent::TurnError { message, .. })) => {
1351 assert!(
1352 message.contains("before active session"),
1353 "the typed failure carries the early Err: {message}"
1354 );
1355 }
1356 other => panic!("expected a TurnError from the client-less early Err, got {other:?}"),
1357 }
1358 }
1359
1360 #[tokio::test]
1366 async fn set_mode_without_client_emits_set_mode_failed() {
1367 let bridge = test_bridge();
1368 bind_session_slot(&bridge, "session-1");
1369 let mut events = bridge.take_events().expect("fresh bridge yields its events receiver");
1370
1371 bridge
1372 .set_mode("session-1".to_owned(), PermissionMode::AcceptEdits)
1373 .expect_err("no client, dispatch refused");
1374
1375 match tokio::time::timeout(std::time::Duration::from_secs(2), events.recv()).await {
1376 Ok(Some(AgentEvent::SetModeFailed { session_id, mode, message })) => {
1377 assert_eq!(session_id, "session-1");
1378 assert_eq!(mode, PermissionMode::AcceptEdits);
1379 assert!(
1380 message.contains("set_mode never reached the CLI"),
1381 "the typed failure names the op and carries the early Err: {message}"
1382 );
1383 }
1384 other => panic!("expected SetModeFailed from the client-less early Err, got {other:?}"),
1385 }
1386 }
1387
1388 #[tokio::test]
1389 async fn set_model_without_client_emits_set_model_failed() {
1390 let bridge = test_bridge();
1391 bind_session_slot(&bridge, "session-1");
1392 let mut events = bridge.take_events().expect("fresh bridge yields its events receiver");
1393
1394 bridge
1395 .set_model("session-1".to_owned(), "claude-sonnet-5".to_owned())
1396 .expect_err("no client, dispatch refused");
1397
1398 match tokio::time::timeout(std::time::Duration::from_secs(2), events.recv()).await {
1399 Ok(Some(AgentEvent::SetModelFailed { session_id, model, message })) => {
1400 assert_eq!(session_id, "session-1");
1401 assert_eq!(model, "claude-sonnet-5");
1402 assert!(
1403 message.contains("set_model never reached the CLI"),
1404 "the typed failure names the op and carries the early Err: {message}"
1405 );
1406 }
1407 other => {
1408 panic!("expected SetModelFailed from the client-less early Err, got {other:?}")
1409 }
1410 }
1411 }
1412
1413 #[test]
1414 fn set_mode_rejection_text_strips_the_control_failed_wrapper() {
1415 let err = forge_sdk::Error::message_parse("control failed: mode not permitted");
1416 assert_eq!(ForgeSdkBridge::set_mode_rejection_text(&err), "mode not permitted");
1417 let other = forge_sdk::Error::Connection {
1418 reason: "subprocess closed before set_permission_mode response".into(),
1419 };
1420 assert_eq!(
1421 ForgeSdkBridge::set_mode_rejection_text(&other),
1422 other.to_string(),
1423 "non-control errors pass through as their Display",
1424 );
1425 }
1426
1427 fn sdk_mock_binary() -> String {
1431 concat!(env!("CARGO_MANIFEST_DIR"), "/../forge-sdk/tests/fixtures/mock_claude.sh")
1432 .to_owned()
1433 }
1434
1435 async fn bridge_with_mock_client() -> (ForgeSdkBridge, mpsc::UnboundedReceiver<AgentEvent>) {
1436 bridge_with_mock_client_opts(forge_sdk::OptionsBuilder::new()).await
1437 }
1438
1439 async fn bridge_with_mock_client_opts(
1440 builder: forge_sdk::OptionsBuilder,
1441 ) -> (ForgeSdkBridge, mpsc::UnboundedReceiver<AgentEvent>) {
1442 let bridge = ForgeSdkBridge::default();
1443 let events = bridge.take_events().expect("fresh bridge yields its events receiver");
1444 let opts = builder.binary(sdk_mock_binary()).build();
1445 let (client, _client_events) = forge_sdk::Client::spawn(opts).await.expect("mock client");
1446 bridge.set_client(client);
1447 (bridge, events)
1448 }
1449
1450 fn control_mock_binary() -> String {
1453 concat!(env!("CARGO_MANIFEST_DIR"), "/../forge-sdk/tests/fixtures/mock_claude_control.sh")
1454 .to_owned()
1455 }
1456
1457 async fn bridge_with_control_mock(
1458 echo: &std::path::Path,
1459 ) -> (ForgeSdkBridge, mpsc::UnboundedReceiver<AgentEvent>) {
1460 let bridge = ForgeSdkBridge::default();
1461 let events = bridge.take_events().expect("fresh bridge yields its events receiver");
1462 let opts = forge_sdk::OptionsBuilder::new()
1463 .binary(control_mock_binary())
1464 .env("FORGED_MOCK_ECHO_SUBTYPE", echo.to_string_lossy().as_ref())
1465 .build();
1466 let (client, _client_events) = forge_sdk::Client::spawn(opts).await.expect("mock client");
1467 bridge.set_client(client);
1468 (bridge, events)
1469 }
1470
1471 fn observed_subtypes(echo: &std::path::Path) -> Vec<String> {
1473 std::fs::read_to_string(echo)
1474 .expect("the mock echoes observed subtypes to the file")
1475 .lines()
1476 .map(str::to_owned)
1477 .collect()
1478 }
1479
1480 #[tokio::test]
1488 async fn a_context_probe_already_in_flight_is_not_asked_again() {
1489 let dir = tempfile::tempdir().expect("tempdir");
1490 let echo = dir.path().join("echo");
1491 let (bridge, mut events) = bridge_with_control_mock(&echo).await;
1492
1493 bridge.get_context_usage("session-1".to_owned()).expect("the first ask goes");
1494 bridge
1495 .get_context_usage("session-1".to_owned())
1496 .expect("an ask while one is out is answered, not refused");
1497 bridge.get_context_usage("session-1".to_owned()).expect("and so is a third");
1498 assert_eq!(
1501 bridge.inner.context_probes_in_flight.lock().len(),
1502 1,
1503 "the three asks left one seat marked, which is the one probe that went",
1504 );
1505 let subtypes = drain_answers(&bridge, &mut events, &echo).await;
1506 assert_eq!(
1507 subtypes.iter().filter(|subtype| *subtype == "get_context_usage").count(),
1508 1,
1509 "three asks with one probe out reach the CLI once: {subtypes:?}",
1510 );
1511 assert!(
1512 bridge.inner.context_probes_in_flight.lock().is_empty(),
1513 "and the answer released the seat, so the next ask is not the last",
1514 );
1515
1516 bridge.get_context_usage("session-1".to_owned()).expect("a later ask goes");
1518 let subtypes = drain_answers(&bridge, &mut events, &echo).await;
1519 assert_eq!(
1520 subtypes.iter().filter(|subtype| *subtype == "get_context_usage").count(),
1521 2,
1522 "a further ask reaches the CLI: {subtypes:?}",
1523 );
1524 }
1525
1526 #[tokio::test]
1537 async fn a_wedged_probe_mark_is_reclaimed_by_a_later_ask() {
1538 let dir = tempfile::tempdir().expect("tempdir");
1539 let echo = dir.path().join("echo");
1540 let (bridge, mut events) = bridge_with_control_mock(&echo).await;
1541
1542 let wedged_at = Instant::now()
1543 .checked_sub(ForgeSdkBridge::PROBE_MARK_DEADLINE * 4)
1544 .expect("an instant four deadlines back");
1545 bridge.inner.context_probes_in_flight.lock().insert("session-1".to_owned(), wedged_at);
1546
1547 bridge.get_context_usage("session-1".to_owned()).expect("the ask goes");
1548 assert_ne!(
1552 bridge.inner.context_probes_in_flight.lock().get("session-1").copied(),
1553 Some(wedged_at),
1554 "the ask after the deadline must reclaim the wedged mark, not coalesce behind it",
1555 );
1556
1557 let subtypes = drain_answers(&bridge, &mut events, &echo).await;
1558 assert_eq!(
1559 subtypes.iter().filter(|subtype| *subtype == "get_context_usage").count(),
1560 1,
1561 "the reclaimed ask must reach the CLI as one probe: {subtypes:?}",
1562 );
1563 }
1564
1565 #[test]
1568 fn a_reclaimed_mark_survives_the_wedged_probes_late_release() {
1569 let bridge = test_bridge();
1570 let wedged_at = Instant::now()
1571 .checked_sub(ForgeSdkBridge::PROBE_MARK_DEADLINE * 4)
1572 .expect("an instant four deadlines back");
1573 let wedged = ProbeInFlight {
1574 inner: Arc::clone(&bridge.inner),
1575 session_id: "session-1".to_owned(),
1576 taken_at: wedged_at,
1577 };
1578 bridge.inner.context_probes_in_flight.lock().insert("session-1".to_owned(), wedged_at);
1579
1580 let reclaimed_at = Instant::now();
1581 bridge.inner.context_probes_in_flight.lock().insert("session-1".to_owned(), reclaimed_at);
1582 drop(wedged);
1583
1584 assert_eq!(
1585 bridge.inner.context_probes_in_flight.lock().get("session-1"),
1586 Some(&reclaimed_at),
1587 "the wedged probe's late release must leave the reclaimed mark standing",
1588 );
1589 }
1590
1591 async fn drain_answers(
1608 bridge: &ForgeSdkBridge,
1609 events: &mut mpsc::UnboundedReceiver<AgentEvent>,
1610 echo: &std::path::Path,
1611 ) -> Vec<String> {
1612 wait_for(events, "the probe's answer", &|event| {
1613 matches!(event, AgentEvent::ContextUsage { .. })
1614 })
1615 .await;
1616 bridge.get_mcp_snapshot("session-1".to_owned()).expect("the fence ask goes");
1617 wait_for(events, "the fence's answer", &|event| {
1618 matches!(event, AgentEvent::McpSnapshot { .. })
1619 })
1620 .await;
1621 observed_subtypes(echo)
1622 }
1623
1624 async fn wait_for(
1626 events: &mut mpsc::UnboundedReceiver<AgentEvent>,
1627 what: &str,
1628 wanted: &impl Fn(&AgentEvent) -> bool,
1629 ) -> AgentEvent {
1630 let deadline = std::time::Duration::from_secs(10);
1631 let Ok(Some(event)) = tokio::time::timeout(deadline, async {
1632 loop {
1633 match events.recv().await {
1634 Some(event) if wanted(&event) => return Some(event),
1635 Some(_) => {}
1636 None => return None,
1637 }
1638 }
1639 })
1640 .await
1641 else {
1642 panic!("{what} arrives inside the deadline");
1643 };
1644 event
1645 }
1646
1647 #[tokio::test]
1652 async fn dispatch_failure_emits_the_typed_event() {
1653 let (bridge, mut events) = bridge_with_mock_client().await;
1654
1655 bridge
1656 .dispatch_with_failure(
1657 "prompt",
1658 |_client| async { Err::<(), _>(anyhow::anyhow!("stdin write failed")) },
1659 |err| Some(turn_error("s1".to_owned(), err.to_string())),
1660 )
1661 .expect("dispatch accepted");
1662
1663 match tokio::time::timeout(std::time::Duration::from_secs(2), events.recv()).await {
1664 Ok(Some(AgentEvent::TurnError { message, .. })) => {
1665 assert!(
1666 message.contains("stdin write failed"),
1667 "the typed failure carries the error: {message}"
1668 );
1669 }
1670 other => panic!("expected a TurnError from the dispatch Err arm, got {other:?}"),
1671 }
1672 }
1673
1674 #[tokio::test(start_paused = true)]
1682 async fn set_model_timeout_emits_set_model_failed() {
1683 tokio::time::resume();
1684 let (bridge, mut events) = bridge_with_mock_client_opts(
1685 forge_sdk::OptionsBuilder::new().env("FORGED_MOCK_SKIP_SUBTYPE", "set_model"),
1686 )
1687 .await;
1688 tokio::time::pause();
1689 bind_session_slot(&bridge, "mock-session-001");
1690
1691 bridge
1692 .set_model("mock-session-001".to_owned(), "claude-attempted".to_owned())
1693 .expect("dispatch");
1694
1695 match tokio::time::timeout(std::time::Duration::from_secs(120), events.recv()).await {
1703 Ok(Some(AgentEvent::SetModelFailed { model, message, .. })) => {
1704 assert_eq!(model, "claude-attempted");
1705 assert!(
1706 message == "no response from the CLI"
1707 || message.contains("connection to claude subprocess failed"),
1708 "a SetModelFailed failure text is required: {message}"
1709 );
1710 }
1711 other => panic!("expected SetModelFailed on the wedged set_model, got {other:?}"),
1712 }
1713 }
1714}