Skip to main content

mj_client/
session.rs

1//! The session operations used by interactive control surfaces.
2
3use std::fmt;
4use std::future::Future;
5use std::pin::Pin;
6use std::sync::Arc;
7use std::time::Duration;
8
9use agent_client_protocol::schema::v1::SessionConfigOption;
10use anyhow::{Context, Result, ensure};
11use mj_core::config::Config;
12use mj_core::elicitation::ElicitationResponse;
13use mj_core::state::{ManagedSessionSnapshot, SessionRecord};
14
15use mj_core::relay::{
16    AnalyzeDeltaRepository, RelayCommand, RelayCursor, RelayEvent, RelayOperationalState, RepoDelta,
17};
18use mj_core::worker_launch::ReviewerLaunchConfig;
19
20pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
21
22#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
23#[serde(tag = "kind", content = "detail", rename_all = "snake_case")]
24pub enum ViewError {
25    Unreachable(String),
26    TargetMissing(String),
27    ProjectionIntegrity(String),
28}
29
30impl ViewError {
31    pub fn detail(&self) -> &str {
32        match self {
33            Self::Unreachable(detail)
34            | Self::TargetMissing(detail)
35            | Self::ProjectionIntegrity(detail) => detail,
36        }
37    }
38}
39
40#[derive(Debug, Clone, PartialEq, Default)]
41pub struct ManagedSessionView {
42    pub snapshot: Option<ManagedSessionSnapshot>,
43    pub connected: bool,
44    pub error: Option<ViewError>,
45}
46
47#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
48pub struct RelayAttachment {
49    pub state: RelayOperationalState,
50    pub events: Vec<RelayEvent>,
51    pub through_ordinal: u64,
52    pub through_digest: String,
53}
54
55#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
56pub struct StartedReviewer {
57    pub native_session_id: Option<String>,
58    pub config_options: Vec<SessionConfigOption>,
59    pub reused: bool,
60    pub state: RelayOperationalState,
61}
62
63#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum ReviewerAction {
66    Start {
67        config: Box<ReviewerLaunchConfig>,
68    },
69    Submit {
70        command_id: String,
71        command: RelayCommand,
72    },
73    Attach {
74        after_ordinal: u64,
75        after_digest: String,
76    },
77    Acknowledge {
78        through_ordinal: u64,
79        through_digest: String,
80    },
81    Status,
82    RespondElicitation {
83        elicitation_id: String,
84        response: ElicitationResponse,
85    },
86    Pause,
87    /// Stop only the preparation that owns this generation; stale cleanup must not stop its replacement.
88    PauseGeneration {
89        generation: u64,
90    },
91    CaptureDelta {
92        baselines: std::collections::BTreeMap<std::path::PathBuf, String>,
93    },
94    AdvanceBaseline {
95        trees: std::collections::BTreeMap<std::path::PathBuf, String>,
96    },
97    AnalyzeDelta {
98        repositories: Vec<AnalyzeDeltaRepository>,
99    },
100    TakeLaneDispatches,
101}
102
103impl ReviewerAction {
104    pub const fn operation_name(&self) -> &'static str {
105        match self {
106            Self::Start { .. } => "reviewer_start",
107            Self::Submit { .. } => "reviewer_submit",
108            Self::Attach { .. } => "reviewer_attach",
109            Self::Acknowledge { .. } => "reviewer_acknowledge",
110            Self::Status => "reviewer_status",
111            Self::RespondElicitation { .. } => "reviewer_respond_elicitation",
112            Self::Pause | Self::PauseGeneration { .. } => "reviewer_pause",
113            Self::CaptureDelta { .. } => "reviewer_capture_delta",
114            Self::AdvanceBaseline { .. } => "reviewer_advance_baseline",
115            Self::AnalyzeDelta { .. } => "reviewer_analyze_delta",
116            Self::TakeLaneDispatches => "reviewer_take_lane_dispatches",
117        }
118    }
119}
120
121#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
122#[serde(rename_all = "snake_case")]
123pub enum ReviewerOutcome {
124    Started(Box<StartedReviewer>),
125    Accepted {
126        ordinal: u64,
127    },
128    Attached(Box<RelayAttachment>),
129    Acknowledged(RelayCursor),
130    Status(Box<RelayOperationalState>),
131    ElicitationResolved,
132    Paused,
133    Delta {
134        repositories: Vec<RepoDelta>,
135    },
136    BaselineAdvanced,
137    ChangedFunctions {
138        packet: String,
139    },
140    LaneDispatches {
141        requests: Vec<mj_core::review::lanes::ReviewSubagentRequest>,
142    },
143}
144
145/// Preserve whether a submit failed before delivery or lost its acknowledgement.
146#[derive(Debug)]
147pub struct SubmitFailure {
148    pub message: String,
149    pub unconfirmed: bool,
150}
151impl From<String> for SubmitFailure {
152    fn from(message: String) -> Self {
153        Self {
154            message,
155            unconfirmed: false,
156        }
157    }
158}
159impl From<&str> for SubmitFailure {
160    fn from(message: &str) -> Self {
161        message.to_owned().into()
162    }
163}
164
165/// Submission lost its acknowledgement; callers must reconcile before retrying.
166#[derive(Debug)]
167pub struct DeliveryUnconfirmed;
168impl std::fmt::Display for DeliveryUnconfirmed {
169    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170        f.write_str("delivery unconfirmed")
171    }
172}
173impl std::error::Error for DeliveryUnconfirmed {}
174
175pub struct PendingRelaySubmit {
176    completion: BoxFuture<'static, Result<u64>>,
177}
178
179impl PendingRelaySubmit {
180    pub fn new(completion: BoxFuture<'static, Result<u64>>) -> Self {
181        Self { completion }
182    }
183
184    pub async fn wait(self) -> Result<u64> {
185        self.completion.await
186    }
187}
188
189pub struct PendingRelaySync {
190    completion: BoxFuture<'static, Result<()>>,
191}
192
193impl PendingRelaySync {
194    pub fn new(completion: BoxFuture<'static, Result<()>>) -> Self {
195        Self { completion }
196    }
197
198    pub async fn wait(self) -> Result<()> {
199        self.completion.await
200    }
201}
202
203#[derive(Debug, Default)]
204pub struct ReviewState {
205    pub review: Option<mj_core::storage::StoredReview>,
206}
207
208pub trait SessionHandleBackend: Send + Sync {
209    fn transcript_history(
210        &self,
211        _before: Option<mj_core::storage::TranscriptCursor>,
212    ) -> BoxFuture<'_, Result<mj_core::storage::TranscriptHistoryPage>> {
213        Box::pin(async { anyhow::bail!("earlier conversation history is unavailable") })
214    }
215    fn search_prompts(
216        &self,
217        bundle_id: String,
218        scope: mj_core::storage::HistoryScope,
219        query: String,
220    ) -> BoxFuture<'_, Result<Vec<mj_core::storage::PromptHistoryEntry>>>;
221    fn review_state(&self) -> BoxFuture<'_, Result<ReviewState>>;
222    fn resolve_review_settings(
223        &self,
224        cancelled: Arc<std::sync::atomic::AtomicBool>,
225    ) -> BoxFuture<'_, Result<mj_core::review::settings::ResolvedReviewSettings>> {
226        let _ = cancelled;
227        Box::pin(async { anyhow::bail!("review settings resolution is unavailable") })
228    }
229
230    fn config_result(&self, command_id: String) -> BoxFuture<'_, Result<Option<Option<String>>>>;
231
232    fn clone_box(&self) -> Box<dyn SessionHandleBackend>;
233    fn session_id(&self) -> &str;
234    fn view(&self) -> ManagedSessionView;
235    fn is_stopped(&self) -> bool;
236    fn has_changed(&self) -> Result<bool>;
237    fn changed(&mut self) -> BoxFuture<'_, Result<ManagedSessionView>>;
238    fn enqueue_submit(
239        &self,
240        command_id: String,
241        command: RelayCommand,
242    ) -> BoxFuture<'_, Result<PendingRelaySubmit>>;
243    fn enqueue_sync(&self) -> BoxFuture<'_, Result<PendingRelaySync>>;
244    fn respond_elicitation(
245        &self,
246        elicitation_id: String,
247        response: ElicitationResponse,
248    ) -> BoxFuture<'_, Result<()>>;
249    fn stop_background_task(&self, background_task_id: String) -> BoxFuture<'_, Result<()>>;
250    fn reviewer(
251        &self,
252        role: Option<String>,
253        action: ReviewerAction,
254    ) -> BoxFuture<'_, Result<ReviewerOutcome>>;
255}
256
257pub struct SessionHandle {
258    backend: Box<dyn SessionHandleBackend>,
259}
260
261impl SessionHandle {
262    pub async fn transcript_history(
263        &self,
264        before: Option<mj_core::storage::TranscriptCursor>,
265    ) -> Result<mj_core::storage::TranscriptHistoryPage> {
266        self.backend.transcript_history(before).await
267    }
268
269    pub async fn search_prompts(
270        &self,
271        bundle_id: String,
272        scope: mj_core::storage::HistoryScope,
273        query: String,
274    ) -> Result<Vec<mj_core::storage::PromptHistoryEntry>> {
275        self.backend.search_prompts(bundle_id, scope, query).await
276    }
277    pub async fn review_state(&self) -> Result<ReviewState> {
278        self.backend.review_state().await
279    }
280
281    pub async fn resolve_review_settings(
282        &self,
283        cancelled: Arc<std::sync::atomic::AtomicBool>,
284    ) -> Result<mj_core::review::settings::ResolvedReviewSettings> {
285        self.backend.resolve_review_settings(cancelled).await
286    }
287
288    pub fn new(backend: impl SessionHandleBackend + 'static) -> Self {
289        Self {
290            backend: Box::new(backend),
291        }
292    }
293
294    pub fn session_id(&self) -> &str {
295        self.backend.session_id()
296    }
297
298    pub fn view(&self) -> ManagedSessionView {
299        self.backend.view()
300    }
301
302    pub fn is_stopped(&self) -> bool {
303        self.backend.is_stopped()
304    }
305
306    pub fn has_changed(&self) -> Result<bool> {
307        self.backend.has_changed()
308    }
309
310    pub async fn changed(&mut self) -> Result<ManagedSessionView> {
311        self.backend.changed().await
312    }
313
314    pub async fn submit(&self, command_id: String, command: RelayCommand) -> Result<u64> {
315        self.enqueue_submit(command_id, command).await?.wait().await
316    }
317
318    /// Apply a setting and wait for its durable success or rejection.
319    pub async fn set_config(&self, key: String, value: String) -> Result<()> {
320        self.set_config_with_id(new_command_id("set-config")?, key, value)
321            .await
322    }
323
324    pub async fn set_config_with_id(
325        &self,
326        command_id: String,
327        key: String,
328        value: String,
329    ) -> Result<()> {
330        self.submit(command_id.clone(), RelayCommand::SetConfig { key, value })
331            .await?;
332        tokio::time::timeout(Duration::from_secs(60), async {
333            loop {
334                if let Some(error) = self.backend.config_result(command_id.clone()).await? {
335                    if let Some(error) = error {
336                        anyhow::bail!("{error}");
337                    }
338                    self.sync_now().await?;
339                    return Ok(());
340                }
341                ensure!(
342                    !self.is_stopped(),
343                    "session stopped while applying configuration"
344                );
345                if let Some(error) = self.view().error {
346                    anyhow::bail!("configuration connection failed: {}", error.detail());
347                }
348                tokio::time::sleep(Duration::from_millis(50)).await;
349            }
350        })
351        .await
352        .context("configuration command did not complete within 60 seconds")?
353    }
354
355    /// A follow-up prompt must wait for the mode change, not just admission.
356    pub async fn apply_plan_control(
357        &self,
358        command_id: String,
359        control: mj_core::acp::PlanControl,
360    ) -> Result<()> {
361        match control {
362            mj_core::acp::PlanControl::SetConfig { key, value } => {
363                self.set_config_with_id(command_id, key, value).await
364            }
365            mj_core::acp::PlanControl::SetSessionMode { mode_id } => {
366                self.submit(
367                    command_id,
368                    RelayCommand::SetSessionMode {
369                        mode_id: mode_id.clone(),
370                    },
371                )
372                .await?;
373                tokio::time::timeout(Duration::from_secs(60), async {
374                    loop {
375                        self.sync_now().await?;
376                        if self
377                            .view()
378                            .snapshot
379                            .as_ref()
380                            .and_then(|snapshot| snapshot.operational.modes.as_ref())
381                            .is_some_and(|modes| modes.current_mode_id.to_string() == mode_id)
382                        {
383                            return Ok(());
384                        }
385                        ensure!(!self.is_stopped(), "session stopped while changing mode");
386                        tokio::time::sleep(Duration::from_millis(50)).await;
387                    }
388                })
389                .await
390                .context("session mode change did not complete within 60 seconds")?
391            }
392        }
393    }
394
395    pub async fn enqueue_submit(
396        &self,
397        command_id: String,
398        command: RelayCommand,
399    ) -> Result<PendingRelaySubmit> {
400        self.backend.enqueue_submit(command_id, command).await
401    }
402
403    pub async fn sync_now(&self) -> Result<()> {
404        self.enqueue_sync().await?.wait().await
405    }
406
407    pub async fn enqueue_sync(&self) -> Result<PendingRelaySync> {
408        self.backend.enqueue_sync().await
409    }
410
411    pub async fn respond_elicitation(
412        &self,
413        elicitation_id: String,
414        response: ElicitationResponse,
415    ) -> Result<()> {
416        self.backend
417            .respond_elicitation(elicitation_id, response)
418            .await
419    }
420
421    pub async fn stop_background_task(&self, background_task_id: String) -> Result<()> {
422        self.backend.stop_background_task(background_task_id).await
423    }
424
425    pub async fn reviewer(&self, action: ReviewerAction) -> Result<ReviewerOutcome> {
426        self.reviewer_as(None, action).await
427    }
428
429    pub async fn reviewer_as(
430        &self,
431        role: Option<String>,
432        action: ReviewerAction,
433    ) -> Result<ReviewerOutcome> {
434        self.backend.reviewer(role, action).await
435    }
436}
437
438impl Clone for SessionHandle {
439    fn clone(&self) -> Self {
440        Self {
441            backend: self.backend.clone_box(),
442        }
443    }
444}
445
446impl fmt::Debug for SessionHandle {
447    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
448        formatter
449            .debug_struct("SessionHandle")
450            .field("session_id", &self.session_id())
451            .finish_non_exhaustive()
452    }
453}
454
455pub trait SessionControlBackend: Send + Sync {
456    fn session(&self, session_id: String) -> BoxFuture<'_, Result<SessionHandle>>;
457}
458
459#[derive(Clone)]
460pub struct SessionControl {
461    backend: Arc<dyn SessionControlBackend>,
462}
463
464impl SessionControl {
465    pub fn new(backend: impl SessionControlBackend + 'static) -> Self {
466        Self {
467            backend: Arc::new(backend),
468        }
469    }
470
471    pub async fn session(&self, session_id: impl Into<String>) -> Result<SessionHandle> {
472        self.backend.session(session_id.into()).await
473    }
474
475    pub async fn wait_for_session(
476        &self,
477        session_id: &str,
478        timeout: Duration,
479    ) -> Result<SessionHandle> {
480        tokio::time::timeout(timeout, async {
481            loop {
482                match self.session(session_id.to_owned()).await {
483                    Ok(handle) => return Ok(handle),
484                    Err(error) => {
485                        tracing::trace!(session_id, "waiting for session actor: {error:#}");
486                        tokio::time::sleep(Duration::from_millis(25)).await;
487                    }
488                }
489            }
490        })
491        .await
492        .with_context(|| {
493            format!(
494                "session {session_id} did not become available within {} seconds",
495                timeout.as_secs()
496            )
497        })?
498    }
499}
500
501impl fmt::Debug for SessionControl {
502    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
503        formatter.write_str("SessionControl(..)")
504    }
505}
506
507pub trait ReviewerStagerBackend: Send + Sync {
508    fn stage(
509        &self,
510        config: Config,
511        session: SessionRecord,
512        profile_id: String,
513        generation: u64,
514        cancelled: Arc<std::sync::atomic::AtomicBool>,
515    ) -> Result<ReviewerLaunchConfig>;
516}
517
518#[derive(Clone)]
519pub struct ReviewerStager {
520    backend: Arc<dyn ReviewerStagerBackend>,
521}
522
523impl ReviewerStager {
524    pub fn new(backend: impl ReviewerStagerBackend + 'static) -> Self {
525        Self {
526            backend: Arc::new(backend),
527        }
528    }
529
530    pub fn stage(
531        &self,
532        config: Config,
533        session: SessionRecord,
534        profile_id: String,
535        generation: u64,
536        cancelled: Arc<std::sync::atomic::AtomicBool>,
537    ) -> Result<ReviewerLaunchConfig> {
538        self.backend
539            .stage(config, session, profile_id, generation, cancelled)
540    }
541
542    #[doc(hidden)]
543    pub fn unavailable(message: impl Into<String>) -> Self {
544        Self::new(UnavailableReviewerStager(message.into()))
545    }
546}
547
548impl fmt::Debug for ReviewerStager {
549    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
550        formatter.write_str("ReviewerStager(..)")
551    }
552}
553
554struct UnavailableReviewerStager(String);
555
556impl ReviewerStagerBackend for UnavailableReviewerStager {
557    fn stage(
558        &self,
559        _config: Config,
560        _session: SessionRecord,
561        _profile_id: String,
562        _generation: u64,
563        _cancelled: Arc<std::sync::atomic::AtomicBool>,
564    ) -> Result<ReviewerLaunchConfig> {
565        anyhow::bail!(self.0.clone())
566    }
567}
568
569pub fn new_command_id(prefix: &str) -> Result<String> {
570    ensure!(!prefix.trim().is_empty(), "command ID prefix is required");
571    let mut random = [0_u8; 16];
572    getrandom::fill(&mut random)
573        .map_err(|error| anyhow::anyhow!("generate command ID: {error}"))?;
574    Ok(format!("{prefix}-{}", mj_core::hex::lower_hex(random)))
575}
576
577/// A stopped session and a manager that resolves its live replacement.
578///
579/// Chat's cross-crate tests use this hand-written client fake to verify actor
580/// replacement without depending on the controller implementation crate.
581#[doc(hidden)]
582pub struct ReplacementSessionTestFixture {
583    pub stopped: SessionHandle,
584    pub control: SessionControl,
585    pub submitted: tokio::sync::mpsc::UnboundedReceiver<RelayCommand>,
586    pub replacement_view: Arc<tokio::sync::watch::Sender<ManagedSessionView>>,
587}
588
589#[derive(Clone)]
590struct ReplacementTestSession {
591    #[cfg(test)]
592    history: Option<tokio::sync::mpsc::UnboundedSender<HistoryTestRequest>>,
593    session_id: String,
594    stopped: bool,
595    accepted_ordinal: u64,
596    submitted: Option<tokio::sync::mpsc::UnboundedSender<RelayCommand>>,
597    view: tokio::sync::watch::Receiver<ManagedSessionView>,
598    _view_guard: Option<Arc<tokio::sync::watch::Sender<ManagedSessionView>>>,
599}
600
601impl SessionHandleBackend for ReplacementTestSession {
602    fn search_prompts(
603        &self,
604        _bundle_id: String,
605        _scope: mj_core::storage::HistoryScope,
606        _query: String,
607    ) -> BoxFuture<'_, Result<Vec<mj_core::storage::PromptHistoryEntry>>> {
608        #[cfg(test)]
609        if let Some(history) = &self.history {
610            let (response, result) = tokio::sync::oneshot::channel();
611            let sent = history.send(HistoryTestRequest {
612                bundle_id: _bundle_id,
613                scope: _scope,
614                query: _query,
615                response,
616            });
617            return Box::pin(async move {
618                sent.map_err(|_| anyhow::anyhow!("history backend closed"))?;
619                result
620                    .await
621                    .map_err(|_| anyhow::anyhow!("history response dropped"))?
622            });
623        }
624        Box::pin(async { Ok(Vec::new()) })
625    }
626    fn review_state(&self) -> BoxFuture<'_, Result<ReviewState>> {
627        Box::pin(async { Ok(ReviewState::default()) })
628    }
629
630    fn config_result(&self, _command_id: String) -> BoxFuture<'_, Result<Option<Option<String>>>> {
631        Box::pin(async { Ok(None) })
632    }
633    fn clone_box(&self) -> Box<dyn SessionHandleBackend> {
634        Box::new(self.clone())
635    }
636
637    fn session_id(&self) -> &str {
638        &self.session_id
639    }
640
641    fn view(&self) -> ManagedSessionView {
642        self.view.borrow().clone()
643    }
644
645    fn is_stopped(&self) -> bool {
646        self.stopped
647    }
648
649    fn has_changed(&self) -> Result<bool> {
650        self.view.has_changed().context("session manager stopped")
651    }
652
653    fn changed(&mut self) -> BoxFuture<'_, Result<ManagedSessionView>> {
654        Box::pin(async move {
655            self.view
656                .changed()
657                .await
658                .context("session manager stopped")?;
659            Ok(self.view())
660        })
661    }
662
663    fn enqueue_submit(
664        &self,
665        _command_id: String,
666        command: RelayCommand,
667    ) -> BoxFuture<'_, Result<PendingRelaySubmit>> {
668        let submitted = self.submitted.clone();
669        let stopped = self.stopped;
670        let accepted_ordinal = self.accepted_ordinal;
671        Box::pin(async move {
672            ensure!(!stopped, "session manager stopped");
673            let submitted = submitted.context("unsupported test operation")?;
674            submitted
675                .send(command)
676                .context("test submit observer stopped")?;
677            Ok(PendingRelaySubmit::new(Box::pin(async move {
678                Ok(accepted_ordinal)
679            })))
680        })
681    }
682
683    fn enqueue_sync(&self) -> BoxFuture<'_, Result<PendingRelaySync>> {
684        let stopped = self.stopped;
685        Box::pin(async move {
686            ensure!(!stopped, "session manager stopped");
687            Ok(PendingRelaySync::new(Box::pin(async { Ok(()) })))
688        })
689    }
690
691    fn respond_elicitation(
692        &self,
693        _elicitation_id: String,
694        _response: ElicitationResponse,
695    ) -> BoxFuture<'_, Result<()>> {
696        Box::pin(async { anyhow::bail!("unsupported test operation") })
697    }
698
699    fn stop_background_task(&self, _background_task_id: String) -> BoxFuture<'_, Result<()>> {
700        Box::pin(async { anyhow::bail!("unsupported test operation") })
701    }
702
703    fn reviewer(
704        &self,
705        _role: Option<String>,
706        _action: ReviewerAction,
707    ) -> BoxFuture<'_, Result<ReviewerOutcome>> {
708        Box::pin(async { anyhow::bail!("unsupported test operation") })
709    }
710}
711
712struct ReplacementTestControl {
713    session_id: String,
714    replacement: SessionHandle,
715}
716
717impl SessionControlBackend for ReplacementTestControl {
718    fn session(&self, session_id: String) -> BoxFuture<'_, Result<SessionHandle>> {
719        Box::pin(async move {
720            ensure!(
721                session_id == self.session_id,
722                "session {session_id} is not managed"
723            );
724            Ok(self.replacement.clone())
725        })
726    }
727}
728
729#[doc(hidden)]
730pub fn replacement_session_test_fixture(
731    session_id: &str,
732    accepted_ordinal: u64,
733) -> ReplacementSessionTestFixture {
734    let (stopped_view_tx, stopped_view) =
735        tokio::sync::watch::channel(ManagedSessionView::default());
736    drop(stopped_view_tx);
737    let stopped = SessionHandle::new(ReplacementTestSession {
738        #[cfg(test)]
739        history: None,
740        session_id: session_id.to_owned(),
741        stopped: true,
742        accepted_ordinal,
743        submitted: None,
744        view: stopped_view,
745        _view_guard: None,
746    });
747
748    let (view_tx, view) = tokio::sync::watch::channel(ManagedSessionView::default());
749    let view_tx = Arc::new(view_tx);
750    let (submitted_tx, submitted) = tokio::sync::mpsc::unbounded_channel();
751    let replacement = SessionHandle::new(ReplacementTestSession {
752        #[cfg(test)]
753        history: None,
754        session_id: session_id.to_owned(),
755        stopped: false,
756        accepted_ordinal,
757        submitted: Some(submitted_tx),
758        view,
759        _view_guard: Some(view_tx.clone()),
760    });
761    let control = SessionControl::new(ReplacementTestControl {
762        session_id: session_id.to_owned(),
763        replacement,
764    });
765    ReplacementSessionTestFixture {
766        stopped,
767        control,
768        submitted,
769        replacement_view: view_tx,
770    }
771}
772
773#[cfg(test)]
774struct HistoryTestRequest {
775    bundle_id: String,
776    scope: mj_core::storage::HistoryScope,
777    query: String,
778    response: tokio::sync::oneshot::Sender<Result<Vec<mj_core::storage::PromptHistoryEntry>>>,
779}
780
781#[cfg(test)]
782mod storage_tests {
783    use super::*;
784    use mj_core::storage::{HistoryScope, PromptHistoryEntry};
785
786    #[tokio::test]
787    async fn history_search_yields_until_backend_responds_and_propagates_failures() {
788        let (history, mut requests) = tokio::sync::mpsc::unbounded_channel();
789        let (view_guard, view) = tokio::sync::watch::channel(ManagedSessionView::default());
790        let session = SessionHandle::new(ReplacementTestSession {
791            history: Some(history),
792            session_id: "session".into(),
793            stopped: false,
794            accepted_ordinal: 0,
795            submitted: None,
796            view,
797            _view_guard: Some(Arc::new(view_guard)),
798        });
799        let search =
800            session.search_prompts("bundle".into(), HistoryScope::Project, "needle".into());
801        tokio::pin!(search);
802        let request = tokio::select! {
803            biased;
804            result = &mut search => panic!("search completed before storage replied: {result:?}"),
805            request = requests.recv() => request.unwrap(),
806        };
807        assert_eq!(request.bundle_id, "bundle");
808        assert_eq!(request.scope, HistoryScope::Project);
809        assert_eq!(request.query, "needle");
810        request
811            .response
812            .send(Err(anyhow::anyhow!("storage unavailable")))
813            .unwrap();
814        assert!(
815            search
816                .await
817                .unwrap_err()
818                .to_string()
819                .contains("storage unavailable")
820        );
821
822        let search = session.search_prompts("bundle".into(), HistoryScope::Project, "retry".into());
823        tokio::pin!(search);
824        let request = tokio::select! {
825            biased;
826            result = &mut search => panic!("retry completed before storage replied: {result:?}"),
827            request = requests.recv() => request.unwrap(),
828        };
829        request
830            .response
831            .send(Ok(vec![PromptHistoryEntry {
832                id: 1,
833                session_id: "session".into(),
834                text: "retry works".into(),
835            }]))
836            .unwrap();
837        assert_eq!(search.await.unwrap()[0].text, "retry works");
838    }
839}