Skip to main content

forge_agent/
forge_sdk_bridge.rs

1//! `ForgeSdkBridge` - in-process driver around a [`forge_sdk::Client`].
2//!
3//! Drives a [`forge_sdk::Client`] directly - no Node.js subprocess,
4//! no NDJSON, no command queue. The bridge holds the spawned
5//! `Arc<Client>` and dispatches each method as a direct call
6//! (or a `tokio::spawn`'d async task when the method is
7//! fire-and-forget). Synthesized events (Connected, `PermissionRequest`,
8//! `McpSnapshot`, …) flow back through an `mpsc::UnboundedSender<AgentEvent>`
9//! the bridge owns; consumers grab the matching receiver once via
10//! [`ForgeSdkBridge::take_events`].
11//!
12//! ```text
13//!     view                    ForgeSdkBridge                  forge_sdk::Client
14//!      | trait method            |                                    |
15//!      |------------------------>|  client.method().await             |
16//!      |                         |----------------------------------->|
17//!      |                         |                                    |
18//!      |       AgentEvent        |  reader_loop / callbacks           |
19//!      |<------ event_tx --------+<-----------------------------------|
20//! ```
21
22use 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
38/// Name the class of a spawn failure from the SDK error itself, so a
39/// consumer never has to infer it from the rendered message.
40fn 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
47/// The turn-error event for a failure forge hit itself, classified where
48/// the message is built: a reader branching on the class gets it without
49/// searching the prose.
50fn 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/// Sentinel `config_dir` for `ForgeSdkBridge` test stubs that never
56/// exercise the path. Production code constructs the bridge with a
57/// real account `config_dir`; tests that don't drive a session use
58/// this path so the typed field stays non-optional.
59#[cfg(any(test, feature = "testing"))]
60const TESTING_STUB_CONFIG_DIR: &str = "/tmp/forge-testing-stub";
61
62/// Pending permission responses keyed by `tool_use_id`. The
63/// `can_use_tool` callback parks a oneshot here when the CLI asks;
64/// dispatch drains it when the matching `permission_response` arrives
65/// from a view.
66pub(crate) type PendingResponses =
67    Arc<Mutex<HashMap<String, oneshot::Sender<forge_primitives::PermissionDecision>>>>;
68
69/// Pending question outcomes keyed by `tool_use_id`. The
70/// `AskUserQuestion` driver in the `can_use_tool` callback parks a
71/// fresh oneshot per question, emits a `QuestionRequest`, and awaits
72/// the matching `question_response`.
73pub(crate) type PendingQuestions = Arc<Mutex<HashMap<String, oneshot::Sender<QuestionOutcome>>>>;
74
75/// In-process bridge wrapping a single [`forge_sdk::Client`].
76///
77/// Single instance per connection. The bridge owns the spawned
78/// `forge_sdk::Client`, the `can_use_tool` parking lots, and the
79/// outbound `AgentEvent` channel.
80#[derive(Clone)]
81pub struct ForgeSdkBridge {
82    inner: Arc<BridgeInner>,
83}
84
85pub(crate) struct BridgeInner {
86    /// Set after first `new_session` / `resume_session`; cleared on
87    /// session replace or shutdown.
88    client: Mutex<Option<Client>>,
89    /// Bridge → consumer event emission channel. Cloned freely into the
90    /// reader subtask + `can_use_tool` callback closures.
91    event_tx: mpsc::UnboundedSender<AgentEvent>,
92    /// Single-take receiver handed out via [`ForgeSdkBridge::take_events`].
93    events_rx: Mutex<Option<mpsc::UnboundedReceiver<AgentEvent>>>,
94    /// Permission round-trip parking lot.
95    pub(crate) pending: PendingResponses,
96    /// Question round-trip parking lot.
97    pub(crate) pending_questions: PendingQuestions,
98    /// Current session id, shared with the `can_use_tool` callback so
99    /// permission/question events carry the right `session_id`.
100    pub(crate) session_id_slot: Arc<Mutex<String>>,
101    /// `<config_dir>` this bridge was bound to at construction time
102    /// (workspace-driven - typically the picked account's
103    /// `config_dir`). Threaded into the spawned `claude`
104    /// subprocess as `CLAUDE_CONFIG_DIR` and consulted by every
105    /// in-process accessor (oauth, settings, catalog) so they
106    /// honour the bound account, not whatever `$CLAUDE_CONFIG_DIR`
107    /// the parent shell happened to have.
108    config_dir: PathBuf,
109    /// Forge-internal account display name from `forge.toml`'s
110    /// `[[accounts]]`, set by the workspace picker. `None` when
111    /// `Agent::spawn` is called directly (tests, smoke). When
112    /// present, read back through the handle's `display_name()` so a
113    /// view can render which forge-account the bridge is bound to.
114    display_name: Option<String>,
115    /// Forge-workspace-supplied in-process MCP servers attached at
116    /// every `spawn_session` call. Today this is the per-session
117    /// `forge` MCP server, whose tool surface varies by session kind;
118    /// future modules (worktree, memory, …) slot in alongside under
119    /// the same `forge` server name. Cheap to clone - each entry
120    /// is just a name + a few `Arc<dyn Tool>`s.
121    extra_mcp_servers: Vec<(String, forge_sdk::mcp::McpServer)>,
122    /// The session's resolved forge.toml env - `[env]` merged with
123    /// `[accounts.env]` and the spawning project's
124    /// env - stamped onto the spawned `claude`
125    /// subprocess by `forge_sdk_worker::build_options_with_callback`.
126    /// Empty when `Agent::spawn` is called directly without a
127    /// workspace (tests, smoke) or when no table declares anything.
128    env: HashMap<String, String>,
129    /// Session ids whose context-usage probe is out, each against the instant
130    /// its mark was taken, added when the ask goes and removed when its
131    /// answer, error or timeout lands.
132    ///
133    /// A probe is answered inline over the CLI's whole transcript, and the
134    /// socket asks on every read of a seat that reports no usage, so without
135    /// this one unanswered seat parks a probe per read. A probe that times out
136    /// emits nothing, so a caller that coalesced only on its own side would
137    /// repeat forever.
138    ///
139    /// The instant bounds the mark the way the probe's own timeout cannot:
140    /// every release lives inside the dispatched closure, so a closure that
141    /// never runs leaves a mark nothing will ever clear. Any asker past
142    /// [`ForgeSdkBridge::PROBE_MARK_DEADLINE`] reclaims it instead.
143    context_probes_in_flight: Mutex<HashMap<String, Instant>>,
144}
145
146/// Holds a seat's probe mark while its probe is out, and releases it however
147/// the probe ends: an answer, an error, a timeout, or a dispatch that never
148/// reached a client at all.
149///
150/// A guard rather than a release at each exit, because the exits multiply - the
151/// timeout returns early, and the closure is dropped uncalled when no client is
152/// bound.
153///
154/// It releases only the mark it took: one reclaimed by a later ask belongs to
155/// that ask's probe, and a late drop must not clear the live mark.
156struct 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    /// Construct a fresh bridge bound to `config_dir` with an
173    /// optional forge-account `display_name`. Both are stored as
174    /// typed fields. `config_dir` is consulted by every in-process
175    /// accessor (oauth, settings, catalog scans) and exported to
176    /// the spawned `claude` subprocess as `CLAUDE_CONFIG_DIR`.
177    /// `display_name`, when present, is read back through the
178    /// handle's `display_name()`. The internal event channel
179    /// is created here; consumers grab the receiver once via
180    /// [`ForgeSdkBridge::take_events`].
181    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    /// The resolved forge.toml env to stamp onto every spawned
206    /// `claude` subprocess. Cloned per `spawn_session` call, but the
207    /// values were read from disk once at forge BOOT - a new bridge
208    /// does not re-read them, so a forge.toml edit needs a forge
209    /// restart, not a new session.
210    pub(crate) fn env(&self) -> HashMap<String, String> {
211        self.inner.env.clone()
212    }
213
214    /// Forge-workspace-supplied in-process MCP servers to attach to
215    /// every spawned `claude` subprocess (e.g. the `forge` server
216    /// carrying the peer-coordination tools - #114 v1). Cheap-clone
217    /// via the `McpServer`'s `Arc<dyn Tool>` internals; called once
218    /// per `spawn_session` invocation.
219    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        // Drain in-flight permission / question oneshots before the
249        // client goes away. The SDK's detached `can_use_tool` /
250        // `question` callback tasks are awaiting these receivers; if
251        // we drop the client without resolving them they leak the
252        // closure (which holds `Arc<event_tx>`, `Arc<PendingResponses>`,
253        // …) and the matching workspace forwarders exit on a closed
254        // channel without notifying the awaiter.
255        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    /// Send an `AgentEvent::McpOperationError` to the consumer; log a
265    /// `BRIDGE_LIFECYCLE` warn if the channel is closed (terminal
266    /// event for a user-visible MCP failure - silent-drop on
267    /// teardown race would lose the only path to surface).
268    /// On send failure we destructure the unsent event back out of
269    /// the `SendError` so the warn log carries `session_id`,
270    /// `server_name`, and the underlying error text - the most
271    /// actionable triage signals.
272    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    /// Spawn a fire-and-forget client call. Logs and drops on failure.
303    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    /// Spawn a fire-and-forget client call. A failure inside the
312    /// spawned future - or the client-less early Err - is emitted as
313    /// the caller's typed event (a `None` maps to log-only) - a view
314    /// rolls back nothing otherwise: the spinner, the optimistic
315    /// chip, the waiting awaiter all unwind only if this event
316    /// arrives.
317    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            // The caller warn-logs this Err and drops it; the typed
331            // event is what unwinds a view's optimistic state.
332            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// `unused_self` / `needless_pass_by_value` are allowed at the impl
379// level: passthrough accessors that delegate to `forge_sdk::*` free
380// functions don't logically use `self`, but every method here
381// preserves the `&self`-receiver shape so AgentHandle wrappers can
382// call them through a single stable seam.
383#[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        // No `check_session_id` here - a view commits to its
406        // thinking state BEFORE this call, and a silent
407        // stale-session drop (returning Ok with no event) would
408        // leave the user's spinner running forever with no
409        // recovery path. If the prompt does land in the wrong
410        // session, normal SDK error surfaces (TurnError, etc.)
411        // tear down Thinking via the existing wire path. We still
412        // log a breadcrumb on mismatch so the bypass is observable.
413        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    /// Drop one prompt still in the CLI's queue. The answer rides
443    /// [`AgentEvent::PromptCancelResolved`] rather than the call's return: the
444    /// dispatcher channel is fire-and-forget, and a caller that reads `false`
445    /// has to tell the reader the prompt was already taken.
446    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    /// `send_control` parks until the CLI's `control_response`; the
511    /// wait is bounded so a silent CLI surfaces as a typed failure
512    /// instead of a chip/spinner stuck on an optimistic change that
513    /// never took.
514    const CONTROL_RESPONSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
515
516    /// How long a seat's context-usage mark may stand before any asker may
517    /// reclaim it. The probe gives up at [`Self::CONTROL_RESPONSE_TIMEOUT`],
518    /// counted from its closure's first poll, so a mark past that bound is
519    /// either already dead or a probe the timeout is about to drop -
520    /// reclaiming costs at worst one overlapping probe, while leaving a dead
521    /// mark costs the seat its reading for the life of the process.
522    const PROBE_MARK_DEADLINE: std::time::Duration = Self::CONTROL_RESPONSE_TIMEOUT;
523
524    /// The CLI's own refusal text for a failed `set_permission_mode`.
525    /// The SDK wraps it twice (`MessageParse` Display + a
526    /// `control failed: ` prefix); strip both so the chat line carries
527    /// the reason, not the wrapper.
528    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            // A session-swap race dropped the dispatch; surface it on
540            // the attempted session so the chip doesn't stay flipped.
541            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            // A session-swap race dropped the dispatch; surface it on
609            // the attempted session so the model chip doesn't stay
610            // flipped.
611            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    /// Verify the requested `session_id` matches the bridge's current
669    /// session before dispatching a user-action method. In a session-swap
670    /// race a `cancel`/`set_mode`/`set_model` for session A could
671    /// otherwise hit session B's `Client`. Logs the mismatch and returns
672    /// false; the caller is expected to drop the dispatch with a no-op
673    /// `Ok(())`. A request carrying no id at all is refused the same way
674    /// and logged at `error`, since there is nothing to match it to.
675    ///
676    /// Other user-action methods (`prompt_with_images`) intentionally
677    /// opt out - see the inline rationale at each call site.
678    fn check_session_id(&self, session_id: &str, label: &'static str) -> bool {
679        // The slot is written once, where a spawn supplies the id the
680        // child will run under, and every production spawn supplies one:
681        // `new_session(None)` needs `fresh_session_id_for` to miss, which
682        // needs a pooled entry with no `registration`, and the account
683        // walk only returns keys from the org pin the pool holds. So a
684        // live bridge's slot is never empty here, and a request that does
685        // not match is stale rather than unaddressable.
686        // An id-less request names no session at all, so there is nothing
687        // to match it against, and accepting it would run the dispatch on
688        // whichever session the bridge happens to hold. Checked before
689        // the equality, since two empty ids would otherwise match.
690        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    /// Sibling of [`Self::check_session_id`] for methods that intentionally
717    /// pass through on mismatch (no silent drop). Logs a breadcrumb at
718    /// `trace` so postmortems can correlate "my prompt vanished" with
719    /// a session-swap race, without raising the noise floor under
720    /// normal operation.
721    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                // The mark outlived its probe's own deadline, so the probe is
762                // not coming back for it: this ask takes the seat over.
763                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            // Refreshes arrive at turn end, session switch, connect
781            // and post-compaction; a wedged CLI must cost one
782            // timed-out probe, not a parked task per refresh with the
783            // Ctx bar frozen. The expiry below releases the mark; a
784            // probe whose closure never runs at all is the park this
785            // timeout cannot reach, and the mark's own deadline in
786            // `get_context_usage` is what bounds that one.
787            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                    // The mark goes with the timeout's early return: a seat
797                    // whose probe timed out is askable again, rather than
798                    // wedged for the life of its session.
799                    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            // Released before the event is emitted, so an asker woken by the
808            // answer finds the seat free rather than racing the drop.
809            drop(probe);
810            let percentage = forge_sdk_worker::reported_percentage(usage.percentage);
811            // `raw_max_tokens` is the model's nominal context-window
812            // size; `max_tokens` is the effective cap after autocompact
813            // reductions. Forge surfaces the raw size so the panel
814            // shows the model's headline capacity (1M / 200K / …)
815            // rather than a fluctuating effective number.
816            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    /// Decode the launch settings, or report the refusal as the spawn failure
889    /// it is (Ved refused the spawn, 2026-10-15).
890    ///
891    /// **The event is the point of doing it here.** `ConnectionFailed` is what
892    /// the seat's own task records a spawn failure by and rolls the row on, so
893    /// a payload that does not decode leaves a seat that says why and can be
894    /// retried, not one stuck at `Spawning` with nothing to read.
895    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    /// Start a session under `session_id`, or under an id the CLI picks
926    /// when that is `None`.
927    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    /// Re-enter `session_id`, keeping the identity through a refusal.
1009    /// Three attempts, in order: resume it; when the CLI refuses - the
1010    /// transcript is gone - start a new session under the SAME id, which
1011    /// the CLI adopts and which keeps the identity stable; when that is
1012    /// refused too, start under a fresh id rather than failing. The
1013    /// third attempt exists because the first two can both be refused
1014    /// for a reason that is neither of them being absent (an unreadable
1015    /// transcript), and that is a state the pre-id fallback handled
1016    /// silently by starting a new session. Only all three failing
1017    /// surfaces `ConnectionFailed`. Used by project-rooted spawns
1018    /// (Default / Named) where the catalog's recorded lead may be stale
1019    /// (e.g. cross-account scan, deleted file, schema drift). See
1020    /// [`forge_primitives::AgentCommand::ResumeOrNewSession`] for the
1021    /// motivation.
1022    ///
1023    /// `cwd` is also passed to the resume attempt, not just the
1024    /// fallback. `claude --resume <id>` indexes sessions by the
1025    /// project key derived from its own working directory, so
1026    /// inheriting forge's `$PWD` (the default when cwd is empty) makes
1027    /// every cross-directory resume fail with "No conversation found
1028    /// with session ID …" even when the `.jsonl` exists.
1029    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            // Both refused: the id is present on disk but not resumable,
1070            // so create refuses it as already in use. A fresh id is the
1071            // only way the session comes up.
1072            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                        // Three attempts collapsed into one message, so
1097                        // there is no single variant to name.
1098                        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    // The two response handlers below are intentionally exempt from
1114    // `check_session_id` - staleness is detected via the pending map
1115    // (an unknown `tool_call_id` already logs warn in
1116    // `deliver_*_response`). A late response arriving after a session
1117    // swap should still be honoured if its tool_call_id is in the
1118    // shared pending map.
1119
1120    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    // ---- Direct-return accessors (delegate to forge_sdk::*) ----
1145
1146    /// Cheap clone of the bridge's bound `config_dir`. Used by the
1147    /// session worker to thread `CLAUDE_CONFIG_DIR` into the spawned
1148    /// `claude` subprocess and by direct-return accessors.
1149    pub(crate) fn config_dir(&self) -> PathBuf {
1150        self.inner.config_dir.clone()
1151    }
1152
1153    /// Cheap clone of the bridge's bound forge-account
1154    /// `display_name`, when forge-workspace picked one. Used by the
1155    /// session worker to attach `forge_account` to the initial
1156    /// `ForgeAccountIdentity` emit alongside the CLI-side `account`.
1157    pub(crate) fn display_name(&self) -> Option<String> {
1158        self.inner.display_name.clone()
1159    }
1160
1161    /// OS PID of the spawned `claude` child, when a client is bound
1162    /// and the transport reported one. Used by
1163    /// [`crate::env::processes`] to anchor an OS-level walk of the
1164    /// descendant process tree for the Inspector pane's PROCESSES
1165    /// section. Returns `None` before the first `new_session` /
1166    /// `resume_session` lands a client, or after `clear_client`.
1167    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    /// The bridge names the SDK variant, so a consumer classifies on it
1188    /// rather than on rendered prose - the working directory's path runs
1189    /// through `.claude/worktrees/<label>`, which is the word the
1190    /// worktree-creation heuristic keys on.
1191    #[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    /// The class rides the event from where the message is built, so a
1210    /// reader gets the classification instead of searching the prose
1211    /// again - the failure's text is all a `TurnError` carries.
1212    #[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    /// Bind the bridge's id slot. Every production bridge has one from
1236    /// the start: the slot is written once, where the spawn supplies the
1237    /// id the child will run under, and nothing writes it per frame. An
1238    /// unbound slot is therefore a fixture state, and `check_session_id`
1239    /// refuses a dispatch it cannot match rather than accepting any id.
1240    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    /// The match is by id and only by id: a stale id is refused so a
1245    /// `cancel`/`set_mode`/`set_model` meant for one session cannot land
1246    /// on another, and an id-less request is refused rather than being
1247    /// run against whichever session the bridge happens to hold.
1248    #[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    /// An unbound slot refuses rather than matching everything, which is
1262    /// what the old `current.is_empty()` wildcard did. That arm is safe
1263    /// to refuse because nothing writes the slot per frame and every
1264    /// production spawn supplies an id, so a live bridge is never
1265    /// unbound; see the invariant on `check_session_id`.
1266    #[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    /// The client-None window (clear_client, then set_client only
1331    /// after init completes) still routes commands with the old
1332    /// session id, so the caller's typed event is the only thing that
1333    /// unwinds a view's optimistic state - a silent Err return loses
1334    /// the prompt entirely.
1335    #[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    /// The client-None window passes `check_session_id` for both
1361    /// sibling methods (the slot holds the session id the caller
1362    /// dispatches with), so the typed failure is the only thing that
1363    /// unflips the optimistic mode chip - a silent Err return leaves it
1364    /// stuck.
1365    #[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    /// forge-sdk's shared dev fixture (same file the forge-sdk
1428    /// integration tests drive); the bridge only needs a completed
1429    /// handshake behind `set_client`.
1430    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    /// forge-sdk's control mock, whose `FORGED_MOCK_ECHO_SUBTYPE` hook records
1451    /// every observed control subtype to `echo`.
1452    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    /// The control subtypes the mock observed, in order.
1472    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    /// A probe already out for a seat is not asked again.
1481    ///
1482    /// The socket re-asks on every read of a seat that reports no usage, and a
1483    /// client reads its seat on a poll, so an unanswered seat would otherwise
1484    /// park a probe over the CLI's whole transcript per poll - and a probe that
1485    /// times out emits nothing, so the repeat would never stop. The mark is per
1486    /// seat, and released when the answer, the error or the timeout lands.
1487    #[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        // The mark itself, so the coalescing is pinned at the ask rather than
1499        // inferred from the CLI's sight of it.
1500        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        // And the seat is free again once its answer lands.
1517        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    /// A mark left by a probe whose closure never runs is reclaimed by the
1527    /// next ask past the deadline.
1528    ///
1529    /// That closure is the whole of the probe's release machinery: the mark
1530    /// is taken before the dispatch, and an answer, an error and the timeout
1531    /// all live inside it. So a dispatch whose task never reaches its first
1532    /// poll leaves a mark nothing will ever clear, and a seat that coalesces
1533    /// behind it stays unprobed for the life of the process. The deadline is
1534    /// the only bound that reaches this park, so the ask past it must start a
1535    /// fresh probe rather than coalesce.
1536    #[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        // Checked before any wait: a wedged mark either turns over now or it
1549        // stays the wedged one, and the answered probe's release leaves
1550        // `None`, which is not the wedged instant either way.
1551        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    /// The guard releases only the mark it took, so a wedged probe's late
1566    /// release cannot clear the fresh one a reclaim took over.
1567    #[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    /// The subtypes the mock observed, fenced past every ask this call made.
1592    ///
1593    /// The read is fenced, not settled. The mock answers strictly in order, so
1594    /// the answer to a `get_mcp_snapshot` dispatched after the asks proves it
1595    /// has read every request written before that one: what the count below
1596    /// reads is what the test caused, at any load. The probe's own answer is
1597    /// waited for first, which is what keeps the fence last - the asks are
1598    /// dispatched together, so by the time the probe is back, a wrongly
1599    /// dispatched ask is long since written.
1600    ///
1601    /// Both waits are the answers themselves, never a stopwatch: a round trip
1602    /// here crosses a subprocess spawning `python3` three times per control
1603    /// line, and a fixed window expires mid-flight on a loaded machine -
1604    /// reading a slow mock as an ask that never went, which is the verdict
1605    /// this test exists to give. The deadline bounds a probe that never
1606    /// answers.
1607    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    /// The first event `wanted` accepts, inside the deadline.
1625    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    /// A failure inside the spawned dispatch future reaches the consumer
1648    /// as the caller's typed event - the spinner/optimistic state
1649    /// unwinds only when this arrives, so log-only failure is a
1650    /// regression.
1651    #[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    /// A CLI that never answers `set_model` must surface
1675    /// `SetModelFailed` at the 60s budget (virtual time) instead of
1676    /// leaving the optimistic model flip unrecoverable. The wedge rides
1677    /// `Options::env` so the mock subprocess alone skips the subtype.
1678    /// Time runs REAL for the spawn handshake (a paused clock would
1679    /// fire the init budget before the mock's real stdout arrives),
1680    /// then pauses so the 60s dispatch budget elapses virtually.
1681    #[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        // Outer wrapper past the 60s budget: virtual time advances to
1696        // the SOONEST timer, so the dispatch's 60s fires before this.
1697        // Either failure text is a legitimate SetModelFailed cause: the
1698        // timeout's "no response from the CLI", or the connection
1699        // failure's Display when the mock's stdout ends inside the
1700        // virtual-time window. The test's job is the mechanism, not
1701        // which wedge won.
1702        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}