1use std::sync::Arc;
7
8#[cfg(test)]
9use ag_protocol::AgentResponseSummary;
10use ag_protocol::{AgentResponse, ProtocolRequestProfile, build_protocol_repair_prompt};
11use tokio::sync::mpsc;
12
13use crate::agent;
14use crate::app_server::{AppServerClient, AppServerStreamEvent, AppServerTurnRequest};
15use crate::channel::{
16 AgentChannel, AgentError, AgentFuture, SessionRef, StartSessionRequest, TurnEvent, TurnRequest,
17 TurnResult,
18};
19use crate::model::agent::AgentKind;
20
21pub struct AppServerAgentChannel {
28 client: Arc<dyn AppServerClient>,
30 kind: AgentKind,
32}
33
34impl AppServerAgentChannel {
35 pub fn new(client: Arc<dyn AppServerClient>, kind: AgentKind) -> Self {
37 Self { client, kind }
38 }
39}
40
41impl AgentChannel for AppServerAgentChannel {
42 fn start_session(
45 &self,
46 req: StartSessionRequest,
47 ) -> AgentFuture<Result<SessionRef, AgentError>> {
48 let session_id = req.session_id;
49
50 Box::pin(async move { Ok(SessionRef { session_id }) })
51 }
52
53 fn run_turn(
64 &self,
65 session_id: String,
66 req: TurnRequest,
67 events: mpsc::UnboundedSender<TurnEvent>,
68 ) -> AgentFuture<Result<TurnResult, AgentError>> {
69 let client = Arc::clone(&self.client);
70 let kind = self.kind;
71 Box::pin(async move {
72 let request = AppServerTurnRequest {
73 folder: req.folder,
74 live_transcript: req.live_transcript,
75 main_checkout_root: req.main_checkout_root,
76 model: req.model,
77 prompt: req.prompt,
78 request_kind: req.request_kind,
79 replay_transcript: req.replay_transcript,
80 provider_conversation_id: req.provider_conversation_id,
81 persisted_instruction_conversation_id: req.persisted_instruction_conversation_id,
82 reasoning_level: req.reasoning_level,
83 session_id,
84 };
85 let protocol_profile = request.request_kind.protocol_profile();
86 let repair_request = request.clone();
87 let (stream_tx, mut stream_rx) = mpsc::unbounded_channel::<AppServerStreamEvent>();
88
89 let bridge_handle = {
90 let events = events.clone();
91
92 tokio::spawn(async move {
93 while let Some(event) = stream_rx.recv().await {
94 match event {
95 AppServerStreamEvent::AssistantMessage {
96 message,
97 phase,
98 is_delta,
99 } => {
100 let trimmed = message.trim_end();
101 if trimmed.trim().is_empty() {
102 continue;
103 }
104
105 if agent::is_app_server_thought_chunk(
106 kind,
107 is_delta,
108 phase.as_deref(),
109 ) {
110 let _ =
112 events.send(TurnEvent::ThoughtDelta(trimmed.to_string()));
113 }
114 }
115 AppServerStreamEvent::ProgressUpdate(progress) => {
116 let trimmed = progress.trim();
117 if trimmed.is_empty() {
118 continue;
119 }
120
121 let _ = events.send(TurnEvent::ThoughtDelta(trimmed.to_string()));
123 }
124 }
125 }
126 })
127 };
128
129 let turn_result = client.run_turn(request, stream_tx).await;
130 let _ = bridge_handle.await;
132
133 match turn_result {
134 Ok(response) => {
135 let _ = events.send(TurnEvent::PidUpdate(response.pid));
137 let parsed = parse_or_repair_app_server_response(
138 kind,
139 &response,
140 protocol_profile,
141 repair_request,
142 &client,
143 &events,
144 )
145 .await?;
146
147 Ok(TurnResult {
148 assistant_message: parsed.assistant_message,
149 context_reset: response.context_reset,
150 input_tokens: response.input_tokens + parsed.repair_input_tokens,
151 output_tokens: response.output_tokens + parsed.repair_output_tokens,
152 provider_conversation_id: parsed.provider_conversation_id,
153 })
154 }
155 Err(error) => Err(AgentError::AppServer(error)),
156 }
157 })
158 }
159
160 fn shutdown_session(&self, session_id: String) -> AgentFuture<Result<(), AgentError>> {
162 let client = Arc::clone(&self.client);
163
164 Box::pin(async move {
165 client.shutdown_session(session_id).await;
166
167 Ok(())
168 })
169 }
170}
171
172struct AppServerParsedTurnResult {
175 assistant_message: AgentResponse,
177 provider_conversation_id: Option<String>,
181 repair_input_tokens: u64,
184 repair_output_tokens: u64,
187}
188
189async fn parse_or_repair_app_server_response(
200 kind: AgentKind,
201 response: &crate::app_server::AppServerTurnResponse,
202 protocol_profile: ProtocolRequestProfile,
203 repair_request: AppServerTurnRequest,
204 client: &Arc<dyn AppServerClient>,
205 events: &mpsc::UnboundedSender<TurnEvent>,
206) -> Result<AppServerParsedTurnResult, AgentError> {
207 let parse_error =
208 match agent::parse_turn_response(kind, &response.assistant_message, protocol_profile) {
209 Ok(parsed) => {
210 return Ok(AppServerParsedTurnResult {
211 assistant_message: parsed,
212 provider_conversation_id: response.provider_conversation_id.clone(),
213 repair_input_tokens: 0,
214 repair_output_tokens: 0,
215 });
216 }
217 Err(error) => error,
218 };
219
220 let _ = events.send(TurnEvent::ThoughtDelta(format!(
221 "Protocol parse error — retrying: {parse_error}"
222 )));
223
224 let repair_prompt = build_protocol_repair_prompt(&parse_error, &response.assistant_message);
225
226 let repair_provider_conversation_id = response
227 .provider_conversation_id
228 .clone()
229 .or_else(|| repair_request.provider_conversation_id.clone());
230
231 let repair_turn_request = AppServerTurnRequest {
232 folder: repair_request.folder,
233 live_transcript: None,
234 main_checkout_root: repair_request.main_checkout_root,
235 model: repair_request.model,
236 prompt: crate::model::turn_prompt::TurnPrompt::from_agent_data(repair_prompt),
237 request_kind: repair_request.request_kind,
238 replay_transcript: None,
239 provider_conversation_id: repair_provider_conversation_id,
240 persisted_instruction_conversation_id: None,
241 reasoning_level: repair_request.reasoning_level,
242 session_id: repair_request.session_id,
243 };
244 let (repair_stream_tx, _repair_stream_rx) = mpsc::unbounded_channel();
245 let repair_result = client
246 .run_turn(repair_turn_request, repair_stream_tx)
247 .await
248 .map_err(|error| {
249 AgentError::Backend(format!(
250 "{parse_error}\nprotocol repair transport failed: {error}"
251 ))
252 })?;
253
254 let parsed =
255 agent::parse_turn_response(kind, &repair_result.assistant_message, protocol_profile)
256 .map_err(|error| {
257 AgentError::Backend(format!(
258 "{parse_error}\nprotocol repair retry also failed: \
259 {error}\nrepair_response:\n{}",
260 repair_result.assistant_message
261 ))
262 })?;
263
264 Ok(AppServerParsedTurnResult {
265 assistant_message: parsed,
266 provider_conversation_id: repair_result
267 .provider_conversation_id
268 .or(response.provider_conversation_id.clone()),
269 repair_input_tokens: repair_result.input_tokens,
270 repair_output_tokens: repair_result.output_tokens,
271 })
272}
273
274#[cfg(test)]
275mod tests {
276 use std::path::PathBuf;
277 use std::sync::Arc;
278
279 use tokio::sync::mpsc;
280
281 use super::*;
282 use crate::app_server::{AppServerTurnResponse, MockAppServerClient};
283 use crate::channel::AgentRequestKind;
284 use crate::model::agent::ReasoningLevel;
285 use crate::model::turn_prompt::TurnPromptAttachment;
286
287 fn make_turn_request() -> TurnRequest {
288 TurnRequest {
289 folder: PathBuf::from("/tmp"),
290 live_transcript: None,
291 main_checkout_root: Some(PathBuf::from("/tmp/main")),
292 model: "gpt-5.5".to_string(),
293 request_kind: AgentRequestKind::SessionStart,
294 replay_transcript: None,
295 prompt: "Do something".into(),
296 provider_conversation_id: None,
297 persisted_instruction_conversation_id: None,
298 reasoning_level: ReasoningLevel::default(),
299 }
300 }
301
302 fn make_ok_response(assistant_message: &str) -> AppServerTurnResponse {
303 AppServerTurnResponse {
304 assistant_message: assistant_message.to_string(),
305 context_reset: false,
306 input_tokens: 10,
307 output_tokens: 5,
308 pid: None,
309 provider_conversation_id: None,
310 }
311 }
312
313 #[tokio::test]
314 async fn test_run_turn_suppresses_non_thought_assistant_delta_streaming() {
318 let mut mock_client = MockAppServerClient::new();
320 mock_client
321 .expect_run_turn()
322 .returning(|_request, stream_tx| {
323 let _ = stream_tx.send(AppServerStreamEvent::AssistantMessage {
324 message: "Hello world".to_string(),
325 phase: None,
326 is_delta: true,
327 });
328
329 Box::pin(async {
330 Ok(make_ok_response(
331 r#"{"answer":"Hello world","questions":[],"summary":null}"#,
332 ))
333 })
334 });
335 let channel = AppServerAgentChannel::new(Arc::new(mock_client), AgentKind::Codex);
336 let (events_tx, mut events_rx) = mpsc::unbounded_channel();
337
338 let result = channel
340 .run_turn("sess-1".to_string(), make_turn_request(), events_tx)
341 .await;
342
343 assert!(result.is_ok());
345 let events = std::iter::from_fn(|| events_rx.try_recv().ok()).collect::<Vec<_>>();
346 assert!(!events.is_empty());
347 assert!(
348 events
349 .iter()
350 .all(|event| matches!(event, TurnEvent::PidUpdate(_))),
351 "only pid events should be emitted, got: {events:?}"
352 );
353 }
354
355 #[tokio::test]
356 async fn test_run_turn_suppresses_non_delta_assistant_messages() {
359 let mut mock_client = MockAppServerClient::new();
361 mock_client
362 .expect_run_turn()
363 .returning(|_request, stream_tx| {
364 let _ = stream_tx.send(AppServerStreamEvent::AssistantMessage {
365 message: "Full paragraph ".to_string(),
366 phase: None,
367 is_delta: false,
368 });
369
370 Box::pin(async {
371 Ok(make_ok_response(
372 r#"{"answer":"Full paragraph","questions":[],"summary":null}"#,
373 ))
374 })
375 });
376 let channel = AppServerAgentChannel::new(Arc::new(mock_client), AgentKind::Codex);
377 let (events_tx, mut events_rx) = mpsc::unbounded_channel();
378
379 let result = channel
381 .run_turn("sess-1".to_string(), make_turn_request(), events_tx)
382 .await;
383
384 assert!(result.is_ok());
386 let events = std::iter::from_fn(|| events_rx.try_recv().ok()).collect::<Vec<_>>();
387 assert!(!events.is_empty());
388 assert!(
389 events
390 .iter()
391 .all(|event| matches!(event, TurnEvent::PidUpdate(_))),
392 "only pid events should be emitted, got: {events:?}"
393 );
394 }
395
396 #[tokio::test]
397 async fn test_run_turn_suppresses_non_delta_structured_json_streaming() {
400 let mut mock_client = MockAppServerClient::new();
402 mock_client
403 .expect_run_turn()
404 .returning(|_request, stream_tx| {
405 let _ = stream_tx.send(AppServerStreamEvent::AssistantMessage {
406 message: r#"{"answer":"Done.","questions":[{"text":"Need clarification.","options":[]}],"summary":null}"#.to_string(),
407 phase: None,
408 is_delta: false,
409 });
410
411 Box::pin(async {
412 Ok(make_ok_response(
413 r#"{"answer":"Done.","questions":[{"text":"Need clarification.","options":[]}],"summary":null}"#,
414 ))
415 })
416 });
417 let channel = AppServerAgentChannel::new(Arc::new(mock_client), AgentKind::Codex);
418 let (events_tx, mut events_rx) = mpsc::unbounded_channel();
419
420 let result = channel
422 .run_turn("sess-1".to_string(), make_turn_request(), events_tx)
423 .await;
424
425 assert!(result.is_ok());
427 let events = std::iter::from_fn(|| events_rx.try_recv().ok()).collect::<Vec<_>>();
428 assert!(!events.is_empty());
429 assert!(
430 events
431 .iter()
432 .all(|event| matches!(event, TurnEvent::PidUpdate(_))),
433 "only pid events should be emitted, got: {events:?}"
434 );
435 }
436
437 #[tokio::test]
438 async fn test_run_turn_routes_codex_thinking_delta_to_thought_event() {
440 let mut mock_client = MockAppServerClient::new();
442 mock_client
443 .expect_run_turn()
444 .returning(|_request, stream_tx| {
445 let _ = stream_tx.send(AppServerStreamEvent::AssistantMessage {
446 message: "Inspecting files".to_string(),
447 phase: Some("thinking".to_string()),
448 is_delta: true,
449 });
450
451 Box::pin(async {
452 Ok(make_ok_response(
453 r#"{"answer":"Done.","questions":[],"summary":null}"#,
454 ))
455 })
456 });
457 let channel = AppServerAgentChannel::new(Arc::new(mock_client), AgentKind::Codex);
458 let (events_tx, mut events_rx) = mpsc::unbounded_channel();
459
460 let result = channel
462 .run_turn("sess-1".to_string(), make_turn_request(), events_tx)
463 .await;
464
465 assert!(result.is_ok());
467 let event = events_rx.try_recv().expect("should have received an event");
468 assert_eq!(
469 event,
470 TurnEvent::ThoughtDelta("Inspecting files".to_string())
471 );
472 }
473
474 #[tokio::test]
475 async fn test_run_turn_routes_uppercase_codex_thinking_delta_to_thought_event() {
478 let mut mock_client = MockAppServerClient::new();
480 mock_client
481 .expect_run_turn()
482 .returning(|_request, stream_tx| {
483 let _ = stream_tx.send(AppServerStreamEvent::AssistantMessage {
484 message: "Inspecting files".to_string(),
485 phase: Some("Thinking".to_string()),
486 is_delta: true,
487 });
488
489 Box::pin(async {
490 Ok(make_ok_response(
491 r#"{"answer":"Done.","questions":[],"summary":null}"#,
492 ))
493 })
494 });
495 let channel = AppServerAgentChannel::new(Arc::new(mock_client), AgentKind::Codex);
496 let (events_tx, mut events_rx) = mpsc::unbounded_channel();
497
498 let result = channel
500 .run_turn("sess-1".to_string(), make_turn_request(), events_tx)
501 .await;
502
503 assert!(result.is_ok());
505 let event = events_rx.try_recv().expect("should have received an event");
506 assert_eq!(
507 event,
508 TurnEvent::ThoughtDelta("Inspecting files".to_string())
509 );
510 }
511
512 #[tokio::test]
513 async fn test_run_turn_routes_progress_update_events_to_thought_delta() {
515 let mut mock_client = MockAppServerClient::new();
517 mock_client
518 .expect_run_turn()
519 .returning(|_request, stream_tx| {
520 let _ = stream_tx.send(AppServerStreamEvent::ProgressUpdate(
521 "Running tool".to_string(),
522 ));
523
524 Box::pin(async {
525 Ok(make_ok_response(
526 r#"{"answer":"","questions":[],"summary":null}"#,
527 ))
528 })
529 });
530 let channel = AppServerAgentChannel::new(Arc::new(mock_client), AgentKind::Codex);
531 let (events_tx, mut events_rx) = mpsc::unbounded_channel();
532
533 let result = channel
535 .run_turn("sess-1".to_string(), make_turn_request(), events_tx)
536 .await;
537
538 assert!(result.is_ok());
540 let event = events_rx
541 .try_recv()
542 .expect("should have received a progress event");
543 assert_eq!(event, TurnEvent::ThoughtDelta("Running tool".to_string()));
544 }
545
546 #[tokio::test]
547 async fn test_run_turn_fills_missing_summary_for_session_turn() {
550 let mut mock_client = MockAppServerClient::new();
552 mock_client
553 .expect_run_turn()
554 .returning(|_request, _stream_tx| {
555 Box::pin(async {
556 Ok(make_ok_response(
557 r#"{"answer":"Done.","questions":[],"summary":null}"#,
558 ))
559 })
560 });
561 let channel = AppServerAgentChannel::new(Arc::new(mock_client), AgentKind::Antigravity);
562 let (events_tx, _events_rx) = mpsc::unbounded_channel();
563
564 let result = channel
566 .run_turn("sess-1".to_string(), make_turn_request(), events_tx)
567 .await
568 .expect("turn should succeed");
569
570 assert_eq!(
572 result.assistant_message.summary,
573 Some(AgentResponseSummary {
574 turn: String::new(),
575 session: String::new(),
576 })
577 );
578 }
579
580 #[tokio::test]
581 async fn test_run_turn_skips_whitespace_only_assistant_messages() {
584 let mut mock_client = MockAppServerClient::new();
586 mock_client
587 .expect_run_turn()
588 .returning(|_request, stream_tx| {
589 let _ = stream_tx.send(AppServerStreamEvent::AssistantMessage {
590 message: " \n ".to_string(),
591 phase: None,
592 is_delta: true,
593 });
594
595 Box::pin(async {
596 Ok(make_ok_response(
597 r#"{"answer":"","questions":[],"summary":null}"#,
598 ))
599 })
600 });
601 let channel = AppServerAgentChannel::new(Arc::new(mock_client), AgentKind::Codex);
602 let (events_tx, mut events_rx) = mpsc::unbounded_channel();
603
604 let result = channel
606 .run_turn("sess-1".to_string(), make_turn_request(), events_tx)
607 .await;
608
609 assert!(result.is_ok());
611 while let Ok(event) = events_rx.try_recv() {
612 assert!(
613 !matches!(event, TurnEvent::ThoughtDelta(_)),
614 "no ThoughtDelta should be emitted for whitespace-only messages, got: {event:?}"
615 );
616 }
617 }
618
619 #[tokio::test]
620 async fn test_run_turn_skips_delta_protocol_json_fragments() {
623 let mut mock_client = MockAppServerClient::new();
625 mock_client
626 .expect_run_turn()
627 .returning(|_request, stream_tx| {
628 let _ = stream_tx.send(AppServerStreamEvent::AssistantMessage {
629 message: r#"{"answer":"#.to_string(),
630 phase: None,
631 is_delta: true,
632 });
633
634 Box::pin(async {
635 Ok(make_ok_response(
636 r#"{"answer":"Final answer.","questions":[],"summary":null}"#,
637 ))
638 })
639 });
640 let channel = AppServerAgentChannel::new(Arc::new(mock_client), AgentKind::Codex);
641 let (events_tx, mut events_rx) = mpsc::unbounded_channel();
642
643 let result = channel
645 .run_turn("sess-1".to_string(), make_turn_request(), events_tx)
646 .await
647 .expect("turn should succeed");
648
649 assert_eq!(result.assistant_message.to_display_text(), "Final answer.");
651 while let Ok(event) = events_rx.try_recv() {
652 assert!(
653 !matches!(event, TurnEvent::ThoughtDelta(_)),
654 "no ThoughtDelta should be emitted for protocol fragments, got: {event:?}"
655 );
656 }
657 }
658
659 #[tokio::test]
660 async fn test_run_turn_app_server_suppresses_streamed_assistant_messages() {
663 let mut mock_client = MockAppServerClient::new();
665 mock_client
666 .expect_run_turn()
667 .returning(|_request, stream_tx| {
668 let _ = stream_tx.send(AppServerStreamEvent::AssistantMessage {
669 message: "streamed plain text".to_string(),
670 phase: None,
671 is_delta: true,
672 });
673
674 Box::pin(async {
675 Ok(make_ok_response(
676 r#"{"answer":"Final structured output.","questions":[],"summary":null}"#,
677 ))
678 })
679 });
680 let channel = AppServerAgentChannel::new(Arc::new(mock_client), AgentKind::Codex);
681 let (events_tx, mut events_rx) = mpsc::unbounded_channel();
682
683 let result = channel
685 .run_turn("sess-1".to_string(), make_turn_request(), events_tx)
686 .await
687 .expect("turn should succeed");
688
689 assert_eq!(
691 result.assistant_message.to_display_text(),
692 "Final structured output."
693 );
694 while let Ok(event) = events_rx.try_recv() {
695 assert!(
696 !matches!(event, TurnEvent::ThoughtDelta(_)),
697 "no ThoughtDelta should be emitted for plain assistant deltas, got: {event:?}"
698 );
699 }
700 }
701
702 #[tokio::test]
703 async fn test_run_turn_returns_error_for_invalid_structured_output() {
706 let mut mock_client = MockAppServerClient::new();
708 mock_client
709 .expect_run_turn()
710 .times(2)
711 .returning(|request, _stream_tx| {
712 assert_eq!(request.request_kind, AgentRequestKind::SessionStart);
713
714 Box::pin(async { Ok(make_ok_response("plain non-json response")) })
715 });
716 let channel = AppServerAgentChannel::new(Arc::new(mock_client), AgentKind::Codex);
717 let (events_tx, _events_rx) = mpsc::unbounded_channel();
718
719 let error = channel
721 .run_turn("sess-1".to_string(), make_turn_request(), events_tx)
722 .await
723 .expect_err("invalid structured output should fail");
724
725 let error_message = error.to_string();
727 assert!(error_message.contains("did not match the required JSON schema"));
728 assert!(error_message.contains("response:\nplain non-json response"));
729 }
730
731 #[tokio::test]
732 async fn test_run_turn_recovers_valid_output_via_protocol_repair() {
735 let call_counter = Arc::new(std::sync::atomic::AtomicUsize::new(0));
737 let mut mock_client = MockAppServerClient::new();
738 mock_client.expect_run_turn().times(2).returning({
739 let counter = Arc::clone(&call_counter);
740
741 move |_request, _stream_tx| {
742 let call_number = counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
743
744 if call_number == 0 {
745 Box::pin(async { Ok(make_ok_response("plain non-json response")) })
746 } else {
747 Box::pin(async {
748 Ok(make_ok_response(
749 r#"{"answer":"Repaired response","questions":[],"summary":null}"#,
750 ))
751 })
752 }
753 }
754 });
755 let channel = AppServerAgentChannel::new(Arc::new(mock_client), AgentKind::Codex);
756 let (events_tx, _events_rx) = mpsc::unbounded_channel();
757
758 let result = channel
760 .run_turn("sess-1".to_string(), make_turn_request(), events_tx)
761 .await
762 .expect("repair retry should succeed");
763
764 assert_eq!(
766 result.assistant_message.to_display_text(),
767 "Repaired response"
768 );
769 }
770
771 #[tokio::test]
772 async fn test_run_turn_allows_image_attachments() {
775 let mut mock_client = MockAppServerClient::new();
777 mock_client
778 .expect_run_turn()
779 .times(1)
780 .returning(|request, _stream_tx| {
781 assert_eq!(request.prompt.attachments.len(), 1);
782
783 Box::pin(async {
784 Ok(make_ok_response(
785 r#"{"answer":"codex ok","questions":[],"summary":null}"#,
786 ))
787 })
788 });
789 let channel = AppServerAgentChannel::new(Arc::new(mock_client), AgentKind::Codex);
790 let (events_tx, _events_rx) = mpsc::unbounded_channel();
791 let mut request = make_turn_request();
792 request.prompt.attachments.push(TurnPromptAttachment {
793 placeholder: "[Image #1]".to_string(),
794 local_image_path: PathBuf::from("/tmp/image.png"),
795 });
796
797 let result = channel
799 .run_turn("sess-1".to_string(), request, events_tx)
800 .await
801 .expect("turn should succeed");
802
803 assert_eq!(result.assistant_message.to_display_text(), "codex ok");
805 }
806
807 #[tokio::test]
808 async fn test_run_turn_codex_rejects_plain_text_after_repair_retry() {
811 let mut mock_client = MockAppServerClient::new();
813 mock_client
814 .expect_run_turn()
815 .times(2)
816 .returning(|_request, _stream_tx| Box::pin(async { Ok(make_ok_response("plain")) }));
817 let channel = AppServerAgentChannel::new(Arc::new(mock_client), AgentKind::Codex);
818 let (events_tx, _events_rx) = mpsc::unbounded_channel();
819
820 let error = channel
822 .run_turn("sess-1".to_string(), make_turn_request(), events_tx)
823 .await
824 .expect_err("plain-text turn should fail");
825
826 let error_message = error.to_string();
828 assert!(error_message.contains("did not match the required JSON schema"));
829 assert!(error_message.contains("response:\nplain"));
830 }
831
832 #[tokio::test]
833 async fn test_run_turn_client_failure_returns_agent_error() {
835 let mut mock_client = MockAppServerClient::new();
837 mock_client
838 .expect_run_turn()
839 .returning(|_request, _stream_tx| {
840 Box::pin(async {
841 Err(crate::app_server::AppServerError::Provider(
842 "server timeout".to_string(),
843 ))
844 })
845 });
846 let channel = AppServerAgentChannel::new(Arc::new(mock_client), AgentKind::Codex);
847 let (events_tx, _events_rx) = mpsc::unbounded_channel();
848
849 let result = channel
851 .run_turn("sess-1".to_string(), make_turn_request(), events_tx)
852 .await;
853
854 let error_message = result
856 .expect_err("expected Err on server timeout")
857 .to_string();
858 assert!(error_message.contains("server timeout"));
859 }
860
861 #[tokio::test]
862 async fn test_run_turn_returns_correct_token_counts_and_context_reset() {
865 let mut mock_client = MockAppServerClient::new();
867 mock_client
868 .expect_run_turn()
869 .returning(|_request, _stream_tx| {
870 Box::pin(async {
871 Ok(AppServerTurnResponse {
872 assistant_message: r#"{"answer":"Result","questions":[],"summary":null}"#
873 .to_string(),
874 context_reset: true,
875 input_tokens: 100,
876 output_tokens: 50,
877 pid: Some(1234),
878 provider_conversation_id: None,
879 })
880 })
881 });
882 let channel = AppServerAgentChannel::new(Arc::new(mock_client), AgentKind::Codex);
883 let (events_tx, _events_rx) = mpsc::unbounded_channel();
884
885 let result = channel
887 .run_turn("sess-1".to_string(), make_turn_request(), events_tx)
888 .await
889 .expect("turn should succeed");
890
891 assert_eq!(result.assistant_message.to_display_text(), "Result");
893 assert!(result.context_reset);
894 assert_eq!(result.input_tokens, 100);
895 assert_eq!(result.output_tokens, 50);
896 }
897
898 #[tokio::test]
899 async fn test_run_turn_passes_and_returns_provider_conversation_id() {
903 let mut mock_client = MockAppServerClient::new();
905 mock_client
906 .expect_run_turn()
907 .returning(|request, _stream_tx| {
908 assert_eq!(
909 request.provider_conversation_id,
910 Some("thread-abc".to_string()),
911 "request should carry the provider conversation id"
912 );
913 assert_eq!(
914 request.reasoning_level,
915 ReasoningLevel::Medium,
916 "request should carry the codex reasoning level"
917 );
918
919 Box::pin(async {
920 Ok(AppServerTurnResponse {
921 assistant_message: r#"{"answer":"ok","questions":[],"summary":null}"#
922 .to_string(),
923 context_reset: false,
924 input_tokens: 1,
925 output_tokens: 1,
926 pid: Some(42),
927 provider_conversation_id: Some("thread-xyz".to_string()),
928 })
929 })
930 });
931 let channel = AppServerAgentChannel::new(Arc::new(mock_client), AgentKind::Codex);
932 let (events_tx, mut events_rx) = mpsc::unbounded_channel();
933 let mut request = make_turn_request();
934 request.reasoning_level = ReasoningLevel::Medium;
935 request.provider_conversation_id = Some("thread-abc".to_string());
936
937 let result = channel
939 .run_turn("sess-1".to_string(), request, events_tx)
940 .await
941 .expect("turn should succeed");
942
943 assert_eq!(
945 result.provider_conversation_id,
946 Some("thread-xyz".to_string()),
947 "result should carry the provider conversation id from the response"
948 );
949
950 let mut pid_event_seen = false;
952 while let Ok(event) = events_rx.try_recv() {
953 if matches!(event, TurnEvent::PidUpdate(Some(42))) {
954 pid_event_seen = true;
955 }
956 }
957 assert!(pid_event_seen, "should emit PidUpdate from response pid");
958 }
959}