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