1use 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 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#[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#[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 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 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#[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}