1use ag_protocol::ProtocolSchemaInstructionMode;
4
5use super::contract::{
6 AppServerFuture, AppServerTurnRequest, AppServerTurnResponse, BorrowedAppServerFuture,
7};
8use super::error::AppServerError;
9use super::prompt::{
10 instruction_delivery_mode_for_runtime, read_latest_replay_transcript, turn_prompt_for_runtime,
11};
12use super::registry::{ActiveAppServerTurn, AppServerSessionRegistry};
13use crate::model::turn_prompt::TurnPrompt;
14
15pub struct RuntimeInspector<Runtime> {
21 pub matches_request: fn(&Runtime, &AppServerTurnRequest) -> bool,
23 pub pid: fn(&Runtime) -> Option<u32>,
25 pub provider_conversation_id: fn(&Runtime) -> Option<String>,
27 pub restored_context: fn(&Runtime) -> bool,
29}
30
31pub async fn run_turn_with_restart_retry<Runtime, StartRuntime, RunTurn, ShutdownRuntime>(
49 sessions: &AppServerSessionRegistry<Runtime>,
50 request: AppServerTurnRequest,
51 inspector: RuntimeInspector<Runtime>,
52 schema_instruction_mode: ProtocolSchemaInstructionMode,
53 mut start_runtime: StartRuntime,
54 mut run_turn_with_runtime: RunTurn,
55 mut shutdown_runtime: ShutdownRuntime,
56) -> Result<AppServerTurnResponse, AppServerError>
57where
58 StartRuntime: FnMut(&AppServerTurnRequest) -> AppServerFuture<Result<Runtime, AppServerError>>,
59 RunTurn: for<'scope> FnMut(
60 &'scope mut Runtime,
61 &'scope TurnPrompt,
62 ) -> BorrowedAppServerFuture<
63 'scope,
64 Result<(String, u64, u64), AppServerError>,
65 >,
66 ShutdownRuntime: for<'scope> FnMut(&'scope mut Runtime) -> BorrowedAppServerFuture<'scope, ()>,
67{
68 let session_id = request.session_id.clone();
69 let active_turn = sessions.register_active_turn(&session_id)?;
70 let session_runtime =
71 take_compatible_session_runtime(sessions, &request, &inspector, &mut shutdown_runtime)
72 .await?;
73
74 let had_existing_runtime = session_runtime.is_some();
75 let mut session_runtime = match session_runtime {
76 Some(existing_runtime) => existing_runtime,
77 None => start_runtime(&request).await?,
78 };
79 let first_replays = needs_replay(had_existing_runtime, &request, &inspector, &session_runtime);
80 let first_provider_conversation_id = (inspector.provider_conversation_id)(&session_runtime);
81 let first_prompt = build_attempt_prompt(
82 &request,
83 first_replays,
84 first_provider_conversation_id.as_deref(),
85 schema_instruction_mode,
86 &mut shutdown_runtime,
87 &mut session_runtime,
88 )
89 .await?;
90 let first_attempt = run_cancellable_turn_attempt(
91 &active_turn,
92 &mut session_runtime,
93 &first_prompt,
94 &mut run_turn_with_runtime,
95 &mut shutdown_runtime,
96 )
97 .await;
98 if let Ok((assistant_message, input_tokens, output_tokens)) = first_attempt {
99 return store_successful_runtime_response(
100 sessions,
101 session_id,
102 session_runtime,
103 first_replays,
104 (assistant_message, input_tokens, output_tokens),
105 &inspector,
106 &mut shutdown_runtime,
107 )
108 .await;
109 }
110
111 let first_error = first_attempt
112 .err()
113 .unwrap_or_else(|| AppServerError::Provider("App-server turn failed".to_string()));
114 if matches!(first_error, AppServerError::InterruptedByUser(_)) {
115 return Err(first_error);
116 }
117
118 shutdown_runtime(&mut session_runtime).await;
119 let mut restarted = start_runtime(&request).await?;
120 let retry_replays = needs_replay(false, &request, &inspector, &restarted);
121 let retry_provider_conversation_id = (inspector.provider_conversation_id)(&restarted);
122 let retry_prompt = build_attempt_prompt(
123 &request,
124 retry_replays,
125 retry_provider_conversation_id.as_deref(),
126 schema_instruction_mode,
127 &mut shutdown_runtime,
128 &mut restarted,
129 )
130 .await?;
131 match run_cancellable_turn_attempt(
132 &active_turn,
133 &mut restarted,
134 &retry_prompt,
135 &mut run_turn_with_runtime,
136 &mut shutdown_runtime,
137 )
138 .await
139 {
140 Ok(attempt_output) => {
141 store_successful_runtime_response(
142 sessions,
143 session_id,
144 restarted,
145 retry_replays,
146 attempt_output,
147 &inspector,
148 &mut shutdown_runtime,
149 )
150 .await
151 }
152 Err(retry_error) => {
153 if matches!(retry_error, AppServerError::InterruptedByUser(_)) {
154 return Err(retry_error);
155 }
156
157 shutdown_runtime(&mut restarted).await;
158
159 Err(AppServerError::RetryExhausted {
160 provider: sessions.provider_name(),
161 first_error: first_error.to_string(),
162 retry_error: retry_error.to_string(),
163 })
164 }
165 }
166}
167
168async fn take_compatible_session_runtime<Runtime, ShutdownRuntime>(
171 sessions: &AppServerSessionRegistry<Runtime>,
172 request: &AppServerTurnRequest,
173 inspector: &RuntimeInspector<Runtime>,
174 shutdown_runtime: &mut ShutdownRuntime,
175) -> Result<Option<Runtime>, AppServerError>
176where
177 ShutdownRuntime: for<'scope> FnMut(&'scope mut Runtime) -> BorrowedAppServerFuture<'scope, ()>,
178{
179 let mut session_runtime = sessions.take_session(&request.session_id)?;
180
181 if session_runtime
182 .as_ref()
183 .is_some_and(|runtime| !(inspector.matches_request)(runtime, request))
184 {
185 if let Some(runtime) = session_runtime.as_mut() {
186 shutdown_runtime(runtime).await;
187 }
188
189 session_runtime = None;
190 }
191
192 Ok(session_runtime)
193}
194
195async fn store_successful_runtime_response<Runtime, ShutdownRuntime>(
201 sessions: &AppServerSessionRegistry<Runtime>,
202 session_id: String,
203 session_runtime: Runtime,
204 context_reset: bool,
205 attempt_output: (String, u64, u64),
206 inspector: &RuntimeInspector<Runtime>,
207 shutdown_runtime: &mut ShutdownRuntime,
208) -> Result<AppServerTurnResponse, AppServerError>
209where
210 ShutdownRuntime: for<'scope> FnMut(&'scope mut Runtime) -> BorrowedAppServerFuture<'scope, ()>,
211{
212 let (assistant_message, input_tokens, output_tokens) = attempt_output;
213 let pid = (inspector.pid)(&session_runtime);
214 let provider_conversation_id = (inspector.provider_conversation_id)(&session_runtime);
215
216 if let Err((error, mut leaked)) = sessions.store_session_or_recover(session_id, session_runtime)
217 {
218 shutdown_runtime(&mut leaked).await;
219
220 return Err(error);
221 }
222
223 Ok(AppServerTurnResponse {
224 assistant_message,
225 context_reset,
226 input_tokens,
227 output_tokens,
228 pid,
229 provider_conversation_id,
230 })
231}
232
233async fn run_cancellable_turn_attempt<Runtime, RunTurn, ShutdownRuntime>(
243 active_turn: &ActiveAppServerTurn,
244 runtime: &mut Runtime,
245 prompt: &TurnPrompt,
246 run_turn_with_runtime: &mut RunTurn,
247 shutdown_runtime: &mut ShutdownRuntime,
248) -> Result<(String, u64, u64), AppServerError>
249where
250 RunTurn: for<'scope> FnMut(
251 &'scope mut Runtime,
252 &'scope TurnPrompt,
253 ) -> BorrowedAppServerFuture<
254 'scope,
255 Result<(String, u64, u64), AppServerError>,
256 >,
257 ShutdownRuntime: for<'scope> FnMut(&'scope mut Runtime) -> BorrowedAppServerFuture<'scope, ()>,
258{
259 let cancellation_token = active_turn.token();
260 if cancellation_token.is_cancelled() {
261 shutdown_runtime(runtime).await;
262
263 return Err(interrupted_by_user_error());
264 }
265
266 let turn_outcome = {
267 let turn_future = run_turn_with_runtime(runtime, prompt);
268 tokio::pin!(turn_future);
269 tokio::select! {
270 result = &mut turn_future => TurnAttemptOutcome::Completed(result),
271 () = cancellation_token.cancelled() => TurnAttemptOutcome::Interrupted,
272 }
273 };
274
275 match turn_outcome {
276 TurnAttemptOutcome::Completed(result) => result,
277 TurnAttemptOutcome::Interrupted => {
278 shutdown_runtime(runtime).await;
279
280 Err(interrupted_by_user_error())
281 }
282 }
283}
284
285enum TurnAttemptOutcome {
287 Completed(Result<(String, u64, u64), AppServerError>),
289 Interrupted,
291}
292
293fn interrupted_by_user_error() -> AppServerError {
295 AppServerError::InterruptedByUser("[Stopped] Session interrupted by user.".to_string())
296}
297
298fn needs_replay<Runtime>(
301 had_existing_runtime: bool,
302 request: &AppServerTurnRequest,
303 inspector: &RuntimeInspector<Runtime>,
304 runtime: &Runtime,
305) -> bool {
306 !had_existing_runtime
307 && read_latest_replay_transcript(request)
308 .as_deref()
309 .is_some_and(|replay_transcript| !replay_transcript.trim().is_empty())
310 && !(inspector.restored_context)(runtime)
311}
312
313async fn build_attempt_prompt<Runtime, ShutdownRuntime>(
316 request: &AppServerTurnRequest,
317 replays_context: bool,
318 current_provider_conversation_id: Option<&str>,
319 schema_instruction_mode: ProtocolSchemaInstructionMode,
320 shutdown_runtime: &mut ShutdownRuntime,
321 runtime: &mut Runtime,
322) -> Result<TurnPrompt, AppServerError>
323where
324 ShutdownRuntime: for<'scope> FnMut(&'scope mut Runtime) -> BorrowedAppServerFuture<'scope, ()>,
325{
326 let replay_transcript = read_latest_replay_transcript(request);
327 let instruction_delivery_mode = instruction_delivery_mode_for_runtime(
328 request,
329 current_provider_conversation_id,
330 replays_context,
331 );
332
333 match turn_prompt_for_runtime(
334 &request.prompt,
335 &request.request_kind,
336 replay_transcript.as_deref(),
337 instruction_delivery_mode,
338 schema_instruction_mode,
339 &request.folder,
340 ) {
341 Ok(prompt) => Ok(prompt),
342 Err(error) => {
343 shutdown_runtime(runtime).await;
344
345 Err(error)
346 }
347 }
348}
349
350#[cfg(test)]
351mod tests {
352 use std::path::PathBuf;
353 use std::sync::atomic::{AtomicUsize, Ordering};
354 use std::sync::{Arc, Mutex};
355
356 use ag_protocol::ProtocolSchemaInstructionMode;
357
358 use super::*;
359 use crate::agent::InstructionDeliveryMode;
360 use crate::channel::{AgentRequestKind, LiveTranscript};
361 use crate::model::agent::ReasoningLevel;
362 use crate::model::turn_prompt::TurnPrompt;
363
364 struct TestRuntime {
365 model: String,
366 }
367
368 #[derive(Debug)]
369 struct TestLiveTranscript {
370 text: String,
371 }
372
373 impl LiveTranscript for TestLiveTranscript {
374 fn replay_text(&self) -> Option<String> {
375 Some(self.text.clone())
376 }
377 }
378
379 fn live_transcript(text: &str) -> Arc<dyn LiveTranscript> {
380 Arc::new(TestLiveTranscript {
381 text: text.to_string(),
382 })
383 }
384
385 fn session_start_request_kind() -> AgentRequestKind {
386 AgentRequestKind::SessionStart
387 }
388
389 fn session_resume_request_kind() -> AgentRequestKind {
390 AgentRequestKind::SessionResume
391 }
392
393 #[test]
394 fn take_session_returns_stored_runtime() {
395 let sessions = AppServerSessionRegistry::new("Test");
397 sessions
398 .store_session(
399 "session-1".to_string(),
400 TestRuntime {
401 model: "model-a".to_string(),
402 },
403 )
404 .expect("store should succeed");
405
406 let session = sessions
408 .take_session("session-1")
409 .expect("take should succeed");
410
411 assert_eq!(
413 session.map(|runtime| runtime.model),
414 Some("model-a".to_string())
415 );
416 }
417
418 #[test]
419 fn turn_prompt_for_runtime_adds_repo_root_path_instructions_without_context_reset() {
420 let prompt = "Implement feature";
422
423 let turn_prompt = turn_prompt_for_runtime(
425 prompt,
426 &session_start_request_kind(),
427 Some("prior context"),
428 InstructionDeliveryMode::BootstrapFull,
429 ProtocolSchemaInstructionMode::PromptSchema,
430 std::path::Path::new("/tmp/agentty-wt/session-1"),
431 )
432 .expect("turn prompt should render");
433
434 assert!(turn_prompt.contains("repository-root-relative POSIX paths"));
436 assert!(turn_prompt.contains("summary"));
437 assert!(turn_prompt.ends_with(prompt));
438 }
439
440 #[test]
441 fn turn_prompt_for_runtime_replays_session_output_after_context_reset_with_path_instructions() {
442 let prompt = "Implement feature";
444
445 let turn_prompt = turn_prompt_for_runtime(
447 prompt,
448 &session_resume_request_kind(),
449 Some("assistant: proposed plan"),
450 InstructionDeliveryMode::BootstrapWithReplay,
451 ProtocolSchemaInstructionMode::PromptSchema,
452 std::path::Path::new("/tmp/agentty-wt/session-1"),
453 )
454 .expect("turn prompt should render");
455
456 assert!(turn_prompt.contains("repository-root-relative POSIX paths"));
458 assert!(turn_prompt.contains("Continue this session using the full transcript below."));
459 assert!(turn_prompt.contains("assistant: proposed plan"));
460 assert!(turn_prompt.contains(prompt));
461 }
462
463 #[test]
464 fn turn_prompt_for_runtime_uses_shared_protocol_wrapper_for_utility_prompts() {
465 let prompt = "Generate title";
467
468 let turn_prompt = turn_prompt_for_runtime(
470 prompt,
471 &AgentRequestKind::UtilityPrompt,
472 None,
473 InstructionDeliveryMode::BootstrapFull,
474 ProtocolSchemaInstructionMode::PromptSchema,
475 std::path::Path::new("/tmp/agentty-wt/session-1"),
476 )
477 .expect("turn prompt should render");
478
479 assert!(turn_prompt.contains("summary"));
481 assert!(turn_prompt.ends_with(prompt));
482 }
483
484 #[test]
485 fn read_latest_replay_transcript_prefers_live_buffer_over_snapshot() {
486 let request = AppServerTurnRequest {
488 folder: PathBuf::from("/tmp"),
489 live_transcript: Some(live_transcript("live content from stream")),
490 main_checkout_root: None,
491 model: "model-a".to_string(),
492 prompt: "Do work".into(),
493 request_kind: session_resume_request_kind(),
494 replay_transcript: Some("queued snapshot".to_string()),
495 provider_conversation_id: None,
496 persisted_instruction_conversation_id: None,
497 reasoning_level: ReasoningLevel::default(),
498 session_id: "session-1".to_string(),
499 };
500
501 let output = read_latest_replay_transcript(&request);
503
504 assert_eq!(output, Some("live content from stream".to_string()));
506 }
507
508 #[test]
509 fn read_latest_replay_transcript_falls_back_to_snapshot_when_live_buffer_is_empty() {
510 let request = AppServerTurnRequest {
512 folder: PathBuf::from("/tmp"),
513 live_transcript: Some(live_transcript("")),
514 main_checkout_root: None,
515 model: "model-a".to_string(),
516 prompt: "Do work".into(),
517 request_kind: session_resume_request_kind(),
518 replay_transcript: Some("queued snapshot".to_string()),
519 provider_conversation_id: None,
520 persisted_instruction_conversation_id: None,
521 reasoning_level: ReasoningLevel::default(),
522 session_id: "session-1".to_string(),
523 };
524
525 let output = read_latest_replay_transcript(&request);
527
528 assert_eq!(output, Some("queued snapshot".to_string()));
530 }
531
532 #[test]
533 fn read_latest_replay_transcript_falls_back_to_snapshot_when_no_live_buffer() {
534 let request = AppServerTurnRequest {
536 folder: PathBuf::from("/tmp"),
537 live_transcript: None,
538 main_checkout_root: None,
539 model: "model-a".to_string(),
540 prompt: "Do work".into(),
541 request_kind: session_resume_request_kind(),
542 replay_transcript: Some("queued snapshot".to_string()),
543 provider_conversation_id: None,
544 persisted_instruction_conversation_id: None,
545 reasoning_level: ReasoningLevel::default(),
546 session_id: "session-1".to_string(),
547 };
548
549 let output = read_latest_replay_transcript(&request);
551
552 assert_eq!(output, Some("queued snapshot".to_string()));
554 }
555
556 #[test]
557 fn read_latest_replay_transcript_returns_none_when_both_are_absent() {
558 let request = AppServerTurnRequest {
560 folder: PathBuf::from("/tmp"),
561 live_transcript: None,
562 main_checkout_root: None,
563 model: "model-a".to_string(),
564 prompt: "Do work".into(),
565 request_kind: session_start_request_kind(),
566 replay_transcript: None,
567 provider_conversation_id: None,
568 persisted_instruction_conversation_id: None,
569 reasoning_level: ReasoningLevel::default(),
570 session_id: "session-1".to_string(),
571 };
572
573 let output = read_latest_replay_transcript(&request);
575
576 assert_eq!(output, None);
578 }
579
580 #[tokio::test]
581 async fn run_turn_with_restart_retry_uses_live_output_on_retry() {
582 let sessions = AppServerSessionRegistry::new("Test");
584 let request = AppServerTurnRequest {
585 folder: PathBuf::from("/tmp"),
586 live_transcript: Some(live_transcript("streamed before crash")),
587 main_checkout_root: Some(PathBuf::from("/tmp/project")),
588 model: "model-a".to_string(),
589 prompt: "Do work".into(),
590 request_kind: session_resume_request_kind(),
591 replay_transcript: Some("queued snapshot".to_string()),
592 provider_conversation_id: None,
593 persisted_instruction_conversation_id: None,
594 reasoning_level: ReasoningLevel::default(),
595 session_id: "session-1".to_string(),
596 };
597 let captured_retry_prompt = Arc::new(Mutex::new(String::new()));
598
599 let response = run_turn_with_restart_retry(
601 &sessions,
602 request,
603 RuntimeInspector {
604 matches_request: |runtime: &TestRuntime, request| runtime.model == request.model,
605 pid: |_runtime| Some(42),
606 provider_conversation_id: |_runtime| None,
607 restored_context: |_runtime| false,
608 },
609 ProtocolSchemaInstructionMode::PromptSchema,
610 |request: &AppServerTurnRequest| {
611 let model = request.model.clone();
612
613 Box::pin(async move { Ok(TestRuntime { model }) })
614 },
615 {
616 let run_count = Arc::new(AtomicUsize::new(0));
617 let captured_retry_prompt = Arc::clone(&captured_retry_prompt);
618 move |_runtime: &mut TestRuntime, prompt: &TurnPrompt| {
619 let attempt = run_count.fetch_add(1, Ordering::SeqCst);
620 let prompt = prompt.to_string();
621 let captured_retry_prompt = Arc::clone(&captured_retry_prompt);
622
623 Box::pin(async move {
624 if attempt == 0 {
625 return Err(AppServerError::Provider("first failure".to_string()));
626 }
627
628 if let Ok(mut guard) = captured_retry_prompt.lock() {
629 *guard = prompt;
630 }
631
632 Ok(("done".to_string(), 7, 3))
633 })
634 }
635 },
636 |_runtime: &mut TestRuntime| Box::pin(async {}),
637 )
638 .await
639 .expect("retry should succeed");
640
641 assert!(response.context_reset);
643 assert_eq!(response.provider_conversation_id, None);
644 let retry_prompt = captured_retry_prompt
645 .lock()
646 .map(|guard| guard.clone())
647 .unwrap_or_default();
648 assert!(
649 retry_prompt.contains("streamed before crash"),
650 "retry prompt should contain live transcript, not queued snapshot"
651 );
652 assert!(
653 !retry_prompt.contains("queued snapshot"),
654 "retry prompt should use live transcript instead of queued snapshot"
655 );
656 }
657
658 #[tokio::test]
659 async fn run_turn_with_restart_retry_restarts_once_after_first_failure() {
660 let sessions = AppServerSessionRegistry::new("Test");
662 let request = AppServerTurnRequest {
663 folder: PathBuf::from("/tmp"),
664 live_transcript: None,
665 main_checkout_root: None,
666 model: "model-a".to_string(),
667 prompt: "Do work".into(),
668 request_kind: session_resume_request_kind(),
669 replay_transcript: Some("previous transcript".to_string()),
670 provider_conversation_id: None,
671 persisted_instruction_conversation_id: None,
672 reasoning_level: ReasoningLevel::default(),
673 session_id: "session-1".to_string(),
674 };
675 let start_count = Arc::new(AtomicUsize::new(0));
676 let run_count = Arc::new(AtomicUsize::new(0));
677 let shutdown_count = Arc::new(AtomicUsize::new(0));
678
679 let response = run_turn_with_restart_retry(
681 &sessions,
682 request,
683 RuntimeInspector {
684 matches_request: |runtime: &TestRuntime, request| runtime.model == request.model,
685 pid: |_runtime| Some(42),
686 provider_conversation_id: |_runtime| None,
687 restored_context: |_runtime| false,
688 },
689 ProtocolSchemaInstructionMode::PromptSchema,
690 {
691 let start_count = Arc::clone(&start_count);
692 move |request: &AppServerTurnRequest| {
693 let start_count = Arc::clone(&start_count);
694 let model = request.model.clone();
695
696 Box::pin(async move {
697 start_count.fetch_add(1, Ordering::SeqCst);
698
699 Ok(TestRuntime { model })
700 })
701 }
702 },
703 {
704 let run_count = Arc::clone(&run_count);
705 move |_runtime, _prompt| {
706 let attempt = run_count.fetch_add(1, Ordering::SeqCst);
707
708 Box::pin(async move {
709 if attempt == 0 {
710 return Err(AppServerError::Provider("first failure".to_string()));
711 }
712
713 Ok(("done".to_string(), 7, 3))
714 })
715 }
716 },
717 {
718 let shutdown_count = Arc::clone(&shutdown_count);
719 move |_runtime| {
720 let shutdown_count = Arc::clone(&shutdown_count);
721
722 Box::pin(async move {
723 shutdown_count.fetch_add(1, Ordering::SeqCst);
724 })
725 }
726 },
727 )
728 .await
729 .expect("retry should succeed");
730
731 assert_eq!(response.assistant_message, "done");
733 assert!(response.context_reset);
734 assert_eq!((response.input_tokens, response.output_tokens), (7, 3));
735 assert_eq!(response.pid, Some(42));
736 assert_eq!(response.provider_conversation_id, None);
737 assert_eq!(start_count.load(Ordering::SeqCst), 2);
738 assert_eq!(run_count.load(Ordering::SeqCst), 2);
739 assert_eq!(shutdown_count.load(Ordering::SeqCst), 1);
740 }
741
742 #[tokio::test]
743 async fn run_turn_with_restart_retry_shutdown_signal_interrupts_in_flight_runtime() {
744 let sessions = AppServerSessionRegistry::new("Test");
746 let request = AppServerTurnRequest {
747 folder: PathBuf::from("/tmp"),
748 live_transcript: None,
749 main_checkout_root: None,
750 model: "model-a".to_string(),
751 prompt: "Do work".into(),
752 request_kind: session_resume_request_kind(),
753 replay_transcript: Some("previous transcript".to_string()),
754 provider_conversation_id: None,
755 persisted_instruction_conversation_id: None,
756 reasoning_level: ReasoningLevel::default(),
757 session_id: "session-1".to_string(),
758 };
759 let run_count = Arc::new(AtomicUsize::new(0));
760 let shutdown_count = Arc::new(AtomicUsize::new(0));
761
762 let result = run_turn_with_restart_retry(
764 &sessions,
765 request,
766 RuntimeInspector {
767 matches_request: |runtime: &TestRuntime, request| runtime.model == request.model,
768 pid: |_runtime| Some(42),
769 provider_conversation_id: |_runtime| None,
770 restored_context: |_runtime| false,
771 },
772 ProtocolSchemaInstructionMode::PromptSchema,
773 |request: &AppServerTurnRequest| {
774 let model = request.model.clone();
775
776 Box::pin(async move { Ok(TestRuntime { model }) })
777 },
778 {
779 let run_count = Arc::clone(&run_count);
780 let sessions = sessions.clone();
781 move |_runtime, _prompt| {
782 let run_count = Arc::clone(&run_count);
783 let sessions = sessions.clone();
784
785 Box::pin(async move {
786 run_count.fetch_add(1, Ordering::SeqCst);
787 sessions
788 .cancel_active_turn("session-1")
789 .expect("cancel should signal active turn");
790 std::future::pending::<Result<(String, u64, u64), AppServerError>>().await
791 })
792 }
793 },
794 {
795 let shutdown_count = Arc::clone(&shutdown_count);
796 move |_runtime| {
797 let shutdown_count = Arc::clone(&shutdown_count);
798
799 Box::pin(async move {
800 shutdown_count.fetch_add(1, Ordering::SeqCst);
801 })
802 }
803 },
804 )
805 .await;
806
807 assert!(matches!(result, Err(AppServerError::InterruptedByUser(_))));
809 assert_eq!(run_count.load(Ordering::SeqCst), 1);
810 assert_eq!(shutdown_count.load(Ordering::SeqCst), 1);
811 }
812
813 #[tokio::test]
816 async fn run_turn_with_restart_retry_skips_replay_when_runtime_restores_context() {
817 let sessions = AppServerSessionRegistry::new("Test");
819 let request = AppServerTurnRequest {
820 folder: PathBuf::from("/tmp"),
821 live_transcript: None,
822 main_checkout_root: None,
823 model: "model-a".to_string(),
824 prompt: "Do work".into(),
825 request_kind: session_resume_request_kind(),
826 replay_transcript: Some("previous transcript".to_string()),
827 provider_conversation_id: Some("thread-123".to_string()),
828 persisted_instruction_conversation_id: None,
829 reasoning_level: ReasoningLevel::default(),
830 session_id: "session-1".to_string(),
831 };
832 let captured_prompt = Arc::new(Mutex::new(String::new()));
833
834 let response = run_turn_with_restart_retry(
836 &sessions,
837 request,
838 RuntimeInspector {
839 matches_request: |runtime: &TestRuntime, request| runtime.model == request.model,
840 pid: |_runtime| Some(24),
841 provider_conversation_id: |_runtime| Some("thread-123".to_string()),
842 restored_context: |_runtime| true,
843 },
844 ProtocolSchemaInstructionMode::PromptSchema,
845 |request: &AppServerTurnRequest| {
846 let model = request.model.clone();
847
848 Box::pin(async move { Ok(TestRuntime { model }) })
849 },
850 {
851 let captured_prompt = Arc::clone(&captured_prompt);
852 move |_runtime: &mut TestRuntime, prompt: &TurnPrompt| {
853 let prompt = prompt.to_string();
854 let captured_prompt = Arc::clone(&captured_prompt);
855
856 Box::pin(async move {
857 if let Ok(mut guard) = captured_prompt.lock() {
858 *guard = prompt;
859 }
860
861 Ok(("done".to_string(), 1, 1))
862 })
863 }
864 },
865 |_runtime: &mut TestRuntime| Box::pin(async {}),
866 )
867 .await
868 .expect("turn should succeed");
869
870 assert_eq!(response.assistant_message, "done");
872 assert!(!response.context_reset);
873 assert_eq!(
874 response.provider_conversation_id,
875 Some("thread-123".to_string())
876 );
877 assert_eq!(response.pid, Some(24));
878 let captured_prompt = captured_prompt
879 .lock()
880 .map(|guard| guard.clone())
881 .unwrap_or_default();
882 assert!(captured_prompt.contains("repository-root-relative POSIX paths"));
883 assert!(captured_prompt.ends_with("Do work"));
884 assert!(!captured_prompt.contains("previous transcript"));
885 }
886}