1use std::io;
28use std::pin::Pin;
29use std::sync::atomic::{AtomicBool, Ordering};
30use std::sync::{Arc, Mutex};
31
32use futures::Future;
33use futures::future::BoxFuture;
34use pi_agent::{AgentMessage, QueueMode};
35use pi_ai::{ImageContent, Model, ModelThinkingLevel};
36use serde::Serialize;
37use serde_json::Value;
38use tokio::io::AsyncRead;
39use tokio::sync::{Notify, mpsc, oneshot};
40use tokio_util::sync::CancellationToken;
41
42use pi_ext::client::{HostUiRequest, HostUiResponse};
43use pi_ext::protocol::{ExtensionErrorEvent, NotifyLevel, SlotPlacement};
44
45use crate::core::agent_session::events::AgentSessionEvent;
46use crate::core::agent_session::extension::{ExtensionBindings, ExtensionMode};
47use crate::core::agent_session::prompt::PreflightCallback;
48use crate::core::agent_session_runtime::{ForkOutcome, ForkPosition};
49use crate::core::compaction::CompactionResult;
50use crate::core::extension_host::{ExtensionUiEvent, HostExtensionRunner};
51use crate::core::output_guard as output_guard_mod;
52use crate::core::sessions::SessionEntry;
53
54use super::extension_ui::ExtensionUiProxy;
55use super::jsonl::{JsonlLineReader, serialize_json_line};
56use super::types::{
57 BashResult, CycleModelData, CycleThinkingLevelData, ForkMessage, RpcCommand,
58 RpcExtensionUiRequest, RpcExtensionUiResponse, RpcResponse, RpcResponseData, RpcSessionState,
59 RpcSessionTreeNode, RpcSlashCommand, SessionStats, StreamingBehavior,
60};
61
62fn to_jsonl<T: Serialize>(value: &T) -> String {
65 serialize_json_line(value).unwrap_or_default()
66}
67
68type SinkFut = Pin<Box<dyn Future<Output = io::Result<()>> + Send>>;
74
75pub trait RpcSink: Send + Sync {
80 fn write_stdout(&self, text: String) -> SinkFut;
82 fn backpressure(&self) -> SinkFut;
84 fn flush(&self) -> SinkFut;
86}
87
88#[derive(Clone, Copy, Debug)]
90pub struct OutputGuardSink;
91
92impl RpcSink for OutputGuardSink {
93 fn write_stdout(&self, text: String) -> SinkFut {
94 Box::pin(async move {
95 output_guard_mod::write_raw_stdout(text)
96 .await
97 .map_err(|e| io::Error::other(e.to_string()))
98 })
99 }
100 fn backpressure(&self) -> SinkFut {
101 Box::pin(async move {
102 output_guard_mod::wait_for_raw_stdout_backpressure()
103 .await
104 .map_err(|e| io::Error::other(e.to_string()))
105 })
106 }
107 fn flush(&self) -> SinkFut {
108 Box::pin(async move {
109 output_guard_mod::flush_raw_stdout()
110 .await
111 .map_err(|e| io::Error::other(e.to_string()))
112 })
113 }
114}
115
116#[derive(Clone, Default)]
118pub struct BufferSink {
119 stdout: Arc<Mutex<Vec<u8>>>,
120}
121
122impl BufferSink {
123 #[must_use]
125 pub fn new() -> Self {
126 Self::default()
127 }
128 #[must_use]
130 pub fn stdout_string(&self) -> String {
131 String::from_utf8_lossy(
132 &self
133 .stdout
134 .lock()
135 .unwrap_or_else(std::sync::PoisonError::into_inner),
136 )
137 .into_owned()
138 }
139 #[must_use]
141 pub fn stdout_lines(&self) -> Vec<String> {
142 self.stdout_string()
143 .lines()
144 .filter(|l| !l.is_empty())
145 .map(str::to_owned)
146 .collect()
147 }
148}
149
150impl RpcSink for BufferSink {
151 fn write_stdout(&self, text: String) -> SinkFut {
152 let buf = Arc::clone(&self.stdout);
153 Box::pin(async move {
154 buf.lock()
155 .unwrap_or_else(std::sync::PoisonError::into_inner)
156 .extend_from_slice(text.as_bytes());
157 Ok(())
158 })
159 }
160 fn backpressure(&self) -> SinkFut {
161 Box::pin(async { Ok(()) })
162 }
163 fn flush(&self) -> SinkFut {
164 Box::pin(async { Ok(()) })
165 }
166}
167
168#[cfg(test)]
169#[derive(Clone, Default)]
170struct GatedSink {
171 stdout: Arc<Mutex<Vec<u8>>>,
172 writes_started: Arc<std::sync::atomic::AtomicUsize>,
173 started: Arc<Notify>,
174 release: Arc<Notify>,
175}
176
177#[cfg(test)]
178impl GatedSink {
179 async fn wait_for_write(&self, target: usize) {
180 loop {
181 let started = self.started.notified();
182 if self.writes_started.load(Ordering::SeqCst) >= target {
183 return;
184 }
185 started.await;
186 }
187 }
188}
189
190#[cfg(test)]
191impl RpcSink for GatedSink {
192 fn write_stdout(&self, text: String) -> SinkFut {
193 let sink = self.clone();
194 Box::pin(async move {
195 sink.writes_started.fetch_add(1, Ordering::SeqCst);
196 sink.started.notify_waiters();
197 sink.release.notified().await;
198 sink.stdout
199 .lock()
200 .unwrap_or_else(std::sync::PoisonError::into_inner)
201 .extend_from_slice(text.as_bytes());
202 Ok(())
203 })
204 }
205
206 fn backpressure(&self) -> SinkFut {
207 Box::pin(async { Ok(()) })
208 }
209
210 fn flush(&self) -> SinkFut {
211 Box::pin(async { Ok(()) })
212 }
213}
214
215pub type RebindCallback = Arc<dyn Fn() -> BoxFuture<'static, ()> + Send + Sync>;
221
222#[derive(Clone, Debug, PartialEq)]
224pub struct ModelCycleResult {
225 pub model: Model,
227 pub thinking_level: ModelThinkingLevel,
229 pub is_scoped: bool,
231}
232
233pub trait RpcSessionHost: Send + Sync {
241 fn prompt(
245 &self,
246 message: String,
247 images: Vec<ImageContent>,
248 streaming_behavior: Option<StreamingBehavior>,
249 preflight: PreflightCallback,
250 ) -> BoxFuture<'static, Result<(), String>>;
251 fn steer(
253 &self,
254 message: String,
255 images: Vec<ImageContent>,
256 ) -> BoxFuture<'static, Result<(), String>>;
257 fn follow_up(
259 &self,
260 message: String,
261 images: Vec<ImageContent>,
262 ) -> BoxFuture<'static, Result<(), String>>;
263 fn abort(&self) -> BoxFuture<'static, ()>;
265
266 fn get_state(&self) -> BoxFuture<'static, RpcSessionState>;
269
270 fn get_available_models(&self) -> BoxFuture<'static, Vec<Model>>;
273 fn set_model(&self, model: Model) -> BoxFuture<'static, Result<(), String>>;
275 fn cycle_model(&self) -> BoxFuture<'static, Option<ModelCycleResult>>;
277 fn set_thinking_level(&self, level: ModelThinkingLevel) -> BoxFuture<'static, bool>;
280 fn cycle_thinking_level(&self) -> BoxFuture<'static, Option<ModelThinkingLevel>>;
282
283 fn set_steering_mode(&self, mode: QueueMode) -> BoxFuture<'static, ()>;
286 fn set_follow_up_mode(&self, mode: QueueMode) -> BoxFuture<'static, ()>;
288
289 fn compact(
292 &self,
293 custom_instructions: Option<String>,
294 ) -> BoxFuture<'static, Result<CompactionResult, String>>;
295 fn set_auto_compaction(&self, enabled: bool) -> BoxFuture<'static, ()>;
297
298 fn set_auto_retry(&self, enabled: bool) -> BoxFuture<'static, ()>;
301 fn abort_retry(&self) -> BoxFuture<'static, ()>;
303
304 fn execute_bash(
307 &self,
308 command: String,
309 exclude_from_context: Option<bool>,
310 ) -> BoxFuture<'static, Result<BashResult, String>>;
311 fn abort_bash(&self) -> BoxFuture<'static, ()>;
313
314 fn get_session_stats(&self) -> BoxFuture<'static, SessionStats>;
317 fn export_to_html(
319 &self,
320 output_path: Option<String>,
321 ) -> BoxFuture<'static, Result<String, String>>;
322 fn set_session_name(&self, name: String) -> BoxFuture<'static, Result<(), String>>;
324
325 fn new_session(
328 &self,
329 parent_session: Option<String>,
330 ) -> BoxFuture<'static, Result<bool, String>>;
331 fn switch_session(&self, session_path: String) -> BoxFuture<'static, Result<bool, String>>;
333 fn fork(
335 &self,
336 entry_id: String,
337 position: ForkPosition,
338 ) -> BoxFuture<'static, Result<ForkOutcome, String>>;
339
340 fn get_entries(&self) -> BoxFuture<'static, Vec<SessionEntry>>;
343 fn get_leaf_id(&self) -> BoxFuture<'static, Option<String>>;
345 fn get_tree(&self) -> BoxFuture<'static, Vec<RpcSessionTreeNode>>;
347 fn get_fork_messages(&self) -> BoxFuture<'static, Vec<ForkMessage>>;
349 fn get_last_assistant_text(&self) -> BoxFuture<'static, Option<String>>;
351 fn get_messages(&self) -> BoxFuture<'static, Vec<AgentMessage>>;
353 fn get_commands(&self) -> BoxFuture<'static, Vec<RpcSlashCommand>>;
355
356 fn subscribe(
359 &self,
360 listener: Arc<dyn Fn(&AgentSessionEvent) + Send + Sync>,
361 ) -> Box<dyn Fn() + Send + Sync>;
362 fn register_backpressure_hook(
364 &self,
365 hook: Arc<dyn Fn() -> BoxFuture<'static, ()> + Send + Sync>,
366 ) -> Box<dyn Fn() + Send + Sync>;
367 fn bind_extensions_rpc(
369 &self,
370 bindings: ExtensionBindings,
371 ) -> BoxFuture<'static, Result<(), String>>;
372 fn host_extension_runner(&self) -> Option<Arc<HostExtensionRunner>> {
374 None
375 }
376
377 fn dispose(&self) -> BoxFuture<'static, ()>;
380 fn set_rebind(&self, callback: Option<RebindCallback>);
382}
383
384#[derive(Clone, Debug, Serialize)]
390pub struct ExtensionErrorOutput {
391 #[serde(rename = "type")]
392 type_name: &'static str,
393 #[serde(rename = "extensionPath")]
394 extension_path: String,
395 event: String,
396 error: String,
397}
398
399impl ExtensionErrorOutput {
400 #[must_use]
402 pub fn new(
403 path: impl Into<String>,
404 event: impl Into<String>,
405 error: impl Into<String>,
406 ) -> Self {
407 Self {
408 type_name: "extension_error",
409 extension_path: path.into(),
410 event: event.into(),
411 error: error.into(),
412 }
413 }
414}
415
416#[derive(Debug, Serialize)]
418#[serde(untagged)]
419pub enum ServerOutput {
420 Response(RpcResponse),
422 Event(Box<AgentSessionEvent>),
424 UiRequest(RpcExtensionUiRequest),
426 ExtensionError(ExtensionErrorOutput),
428}
429
430type UnsubSlot = Mutex<Option<Box<dyn Fn() + Send + Sync>>>;
435
436fn lock_unsub(slot: &UnsubSlot) -> std::sync::MutexGuard<'_, Option<Box<dyn Fn() + Send + Sync>>> {
437 slot.lock()
438 .unwrap_or_else(std::sync::PoisonError::into_inner)
439}
440
441fn take_unsub(slot: &UnsubSlot) {
442 if let Some(unsub) = lock_unsub(slot).take() {
443 unsub();
444 }
445}
446
447enum WriteMessage {
454 Line(String),
455 Drain(oneshot::Sender<()>),
456}
457
458pub(crate) struct ServerState {
459 sink: Arc<dyn RpcSink>,
460 write_tx: mpsc::UnboundedSender<WriteMessage>,
461 proxy: ExtensionUiProxy,
462 shutdown_requested: Arc<AtomicBool>,
463 needs_rebind: Arc<AtomicBool>,
464 signal: Arc<Notify>,
465 unsubscribe_events: UnsubSlot,
466 unsubscribe_backpressure: UnsubSlot,
467 unsubscribe_extension_ui: UnsubSlot,
468}
469
470impl ServerState {
471 fn new(
472 sink: Arc<dyn RpcSink>,
473 write_tx: mpsc::UnboundedSender<WriteMessage>,
474 proxy: ExtensionUiProxy,
475 ) -> Self {
476 Self {
477 sink,
478 write_tx,
479 proxy,
480 shutdown_requested: Arc::new(AtomicBool::new(false)),
481 needs_rebind: Arc::new(AtomicBool::new(false)),
482 signal: Arc::new(Notify::new()),
483 unsubscribe_events: Mutex::new(None),
484 unsubscribe_backpressure: Mutex::new(None),
485 unsubscribe_extension_ui: Mutex::new(None),
486 }
487 }
488
489 fn enqueue(&self, line: String) {
491 let _ = self.write_tx.send(WriteMessage::Line(line));
492 }
493
494 async fn wait_for_output(&self) {
495 let (done_tx, done_rx) = oneshot::channel();
496 if self.write_tx.send(WriteMessage::Drain(done_tx)).is_ok() {
497 let _ = done_rx.await;
498 }
499 }
500
501 pub(crate) async fn rebind<H>(&self, host: &H)
506 where
507 H: RpcSessionHost + ?Sized,
508 {
509 take_unsub(&self.unsubscribe_events);
510 take_unsub(&self.unsubscribe_backpressure);
511 take_unsub(&self.unsubscribe_extension_ui);
512
513 let shutdown_flag = Arc::clone(&self.shutdown_requested);
514 let shutdown_signal = Arc::clone(&self.signal);
515 let error_tx = self.write_tx.clone();
516 let bindings = ExtensionBindings {
517 mode: Some(ExtensionMode::Rpc),
518 shutdown_handler: Some(Arc::new(move || {
519 shutdown_flag.store(true, Ordering::SeqCst);
520 shutdown_signal.notify_one();
521 })),
522 on_error: Some(Arc::new(move |path: &str, event: &str, error: &str| {
523 let output = ExtensionErrorOutput::new(path, event, error);
524 let _ = error_tx.send(WriteMessage::Line(to_jsonl(&output)));
525 })),
526 ..Default::default()
527 };
528 let _ = host.bind_extensions_rpc(bindings).await;
529
530 let event_tx = self.write_tx.clone();
531 let signal = Arc::clone(&self.signal);
532 let unsub = host.subscribe(Arc::new(move |event: &AgentSessionEvent| {
533 let _ = event_tx.send(WriteMessage::Line(to_jsonl(event)));
534 if matches!(event, AgentSessionEvent::AgentSettled) {
535 signal.notify_one();
536 }
537 }));
538 *lock_unsub(&self.unsubscribe_events) = Some(unsub);
539
540 let backpressure_tx = self.write_tx.clone();
541 let unsub_bp = host.register_backpressure_hook(Arc::new(move || {
542 let write_tx = backpressure_tx.clone();
543 Box::pin(async move {
544 let (done_tx, done_rx) = oneshot::channel();
545 if write_tx.send(WriteMessage::Drain(done_tx)).is_ok() {
546 let _ = done_rx.await;
547 }
548 })
549 }));
550 *lock_unsub(&self.unsubscribe_backpressure) = Some(unsub_bp);
551
552 if let Some(runner) = host.host_extension_runner() {
553 let cancel = CancellationToken::new();
554 let cancel_on_unbind = cancel.clone();
555 *lock_unsub(&self.unsubscribe_extension_ui) = Some(Box::new(move || {
556 cancel_on_unbind.cancel();
557 }));
558
559 if let Some(requests) = runner.take_ui_requests() {
560 tokio::spawn(run_extension_dialog_bridge(
561 Arc::clone(&runner),
562 requests,
563 self.proxy.clone(),
564 self.write_tx.clone(),
565 cancel.clone(),
566 ));
567 }
568 tokio::spawn(run_extension_event_bridge(
569 runner,
570 self.write_tx.clone(),
571 cancel,
572 ));
573 }
574 }
575
576 async fn cleanup<H>(&self, host: &H, exit_code: i32)
578 where
579 H: RpcSessionHost + ?Sized,
580 {
581 self.proxy.cancel_all();
582 take_unsub(&self.unsubscribe_events);
583 take_unsub(&self.unsubscribe_backpressure);
584 take_unsub(&self.unsubscribe_extension_ui);
585 host.dispose().await;
586 self.wait_for_output().await;
587 if exit_code != 143 {
588 let _ = self.sink.flush().await;
589 }
590 }
591}
592
593async fn run_extension_dialog_bridge(
594 runner: Arc<HostExtensionRunner>,
595 mut requests: mpsc::Receiver<HostUiRequest>,
596 proxy: ExtensionUiProxy,
597 write_tx: mpsc::UnboundedSender<WriteMessage>,
598 cancel: CancellationToken,
599) {
600 loop {
601 let request = tokio::select! {
602 () = cancel.cancelled() => break,
603 request = requests.recv() => match request {
604 Some(request) => request,
605 None => break,
606 },
607 };
608 let runner = Arc::clone(&runner);
609 let proxy = proxy.clone();
610 let write_tx = write_tx.clone();
611 let request_cancel = cancel.child_token();
612 tokio::spawn(async move {
613 bridge_extension_dialog(runner, request, proxy, write_tx, request_cancel).await;
614 });
615 }
616}
617
618async fn bridge_extension_dialog(
619 runner: Arc<HostExtensionRunner>,
620 request: HostUiRequest,
621 proxy: ExtensionUiProxy,
622 write_tx: mpsc::UnboundedSender<WriteMessage>,
623 cancel: CancellationToken,
624) {
625 let timeout_ms = match &request {
626 HostUiRequest::Select { request, .. } => request.options_meta.timeout_ms,
627 HostUiRequest::Confirm { request, .. } => request.options_meta.timeout_ms,
628 HostUiRequest::Input { request, .. } => request.options_meta.timeout_ms,
629 HostUiRequest::Editor { .. } => None,
630 };
631 let (rpc_request, response_rx) = proxy.create_dialog(|id| match &request {
632 HostUiRequest::Select { request, .. } => RpcExtensionUiRequest::Select {
633 id: id.to_owned(),
634 title: request.title.clone(),
635 options: request.options.clone(),
636 timeout: request.options_meta.timeout_ms,
637 },
638 HostUiRequest::Confirm { request, .. } => RpcExtensionUiRequest::Confirm {
639 id: id.to_owned(),
640 title: request.title.clone(),
641 message: request.message.clone(),
642 timeout: request.options_meta.timeout_ms,
643 },
644 HostUiRequest::Input { request, .. } => RpcExtensionUiRequest::Input {
645 id: id.to_owned(),
646 title: request.title.clone(),
647 placeholder: request.placeholder.clone(),
648 timeout: request.options_meta.timeout_ms,
649 },
650 HostUiRequest::Editor { request, .. } => RpcExtensionUiRequest::Editor {
651 id: id.to_owned(),
652 title: request.title.clone(),
653 prefill: request.prefill.clone(),
654 },
655 });
656 let rpc_id = rpc_request.id().to_owned();
657 let _ = write_tx.send(WriteMessage::Line(to_jsonl(&ServerOutput::UiRequest(
658 rpc_request,
659 ))));
660
661 let response = if let Some(timeout_ms) = timeout_ms {
662 tokio::select! {
663 () = cancel.cancelled() => None,
664 result = tokio::time::timeout(
665 std::time::Duration::from_millis(timeout_ms),
666 response_rx,
667 ) => result.ok().and_then(Result::ok),
668 }
669 } else {
670 tokio::select! {
671 () = cancel.cancelled() => None,
672 result = response_rx => result.ok(),
673 }
674 };
675 if response.is_none() {
676 let _ = proxy.route_response(RpcExtensionUiResponse::Cancelled { id: rpc_id });
677 }
678 let host_response = map_rpc_ui_response(&request, response);
679 let _ = runner.respond_ui(host_response).await;
680}
681
682fn map_rpc_ui_response(
683 request: &HostUiRequest,
684 response: Option<RpcExtensionUiResponse>,
685) -> HostUiResponse {
686 match request {
687 HostUiRequest::Select { id, .. } => HostUiResponse::Select {
688 id: *id,
689 value: match response {
690 Some(RpcExtensionUiResponse::Value { value, .. }) => Some(value),
691 _ => None,
692 },
693 },
694 HostUiRequest::Confirm { id, .. } => HostUiResponse::Confirm {
695 id: *id,
696 confirmed: match response {
697 Some(RpcExtensionUiResponse::Confirmed { confirmed, .. }) => confirmed,
698 _ => false,
699 },
700 },
701 HostUiRequest::Input { id, .. } => HostUiResponse::Input {
702 id: *id,
703 value: match response {
704 Some(RpcExtensionUiResponse::Value { value, .. }) => Some(value),
705 _ => None,
706 },
707 },
708 HostUiRequest::Editor { id, .. } => HostUiResponse::Editor {
709 id: *id,
710 value: match response {
711 Some(RpcExtensionUiResponse::Value { value, .. }) => Some(value),
712 _ => None,
713 },
714 },
715 }
716}
717
718async fn run_extension_event_bridge(
719 runner: Arc<HostExtensionRunner>,
720 write_tx: mpsc::UnboundedSender<WriteMessage>,
721 cancel: CancellationToken,
722) {
723 let mut events = runner.subscribe_ui();
724 let mut errors = runner.subscribe_errors();
725 for slot in runner.current_slots() {
726 let request = map_extension_ui_event(ExtensionUiEvent::Slot(slot));
727 let _ = write_tx.send(WriteMessage::Line(to_jsonl(&ServerOutput::UiRequest(
728 request,
729 ))));
730 }
731 loop {
732 tokio::select! {
733 () = cancel.cancelled() => break,
734 event = events.recv() => {
735 let event = match event {
736 Ok(event) => event,
737 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
738 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
739 };
740 let request = map_extension_ui_event(event);
741 let _ = write_tx.send(WriteMessage::Line(to_jsonl(
742 &ServerOutput::UiRequest(request),
743 )));
744 }
745 error = errors.recv() => {
746 let error = match error {
747 Ok(error) => error,
748 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
749 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
750 };
751 let output = map_extension_error_event(&error);
752 let _ = write_tx.send(WriteMessage::Line(to_jsonl(
753 &ServerOutput::ExtensionError(output),
754 )));
755 }
756 }
757 }
758}
759
760fn map_extension_error_event(error: &ExtensionErrorEvent) -> ExtensionErrorOutput {
761 let mut extension_path = "<runtime>".to_owned();
762 let mut event = error.code.clone();
763 let mut message = error.message.clone();
764 let mut structured = false;
765
766 if let Some(data) = error.data.as_ref() {
767 if let Some(value) = data
768 .get("extensionPath")
769 .or_else(|| data.get("path"))
770 .and_then(Value::as_str)
771 {
772 value.clone_into(&mut extension_path);
773 structured = true;
774 }
775 if let Some(value) = data.get("event").and_then(Value::as_str) {
776 value.clone_into(&mut event);
777 structured = true;
778 }
779 if let Some(value) = data.get("error").and_then(Value::as_str) {
780 value.clone_into(&mut message);
781 structured = true;
782 }
783 }
784
785 if !structured
786 && let Some(rest) = error.message.strip_prefix('[')
787 && let Some((path, rest)) = rest.split_once("] ")
788 && let Some((event_name, error_message)) = rest.split_once(": ")
789 && !path.is_empty()
790 && !event_name.is_empty()
791 {
792 path.clone_into(&mut extension_path);
793 event_name.clone_into(&mut event);
794 error_message.clone_into(&mut message);
795 }
796
797 ExtensionErrorOutput::new(extension_path, event, message)
798}
799
800fn map_extension_ui_event(event: ExtensionUiEvent) -> RpcExtensionUiRequest {
801 match event {
802 ExtensionUiEvent::Notify(notification) => ExtensionUiProxy::notify(
803 ¬ification.message,
804 Some(match notification.level {
805 NotifyLevel::Info => super::types::NotifyType::Info,
806 NotifyLevel::Warning => super::types::NotifyType::Warning,
807 NotifyLevel::Error => super::types::NotifyType::Error,
808 }),
809 ),
810 ExtensionUiEvent::Slot(slot) => {
811 let lines = slot
812 .lines
813 .iter()
814 .map(|line| line.iter().map(|run| run.text.as_str()).collect::<String>())
815 .collect::<Vec<_>>();
816 let placement = match slot.placement {
817 SlotPlacement::Footer | SlotPlacement::BelowEditor => {
818 Some(super::types::WidgetPlacement::BelowEditor)
819 }
820 _ => Some(super::types::WidgetPlacement::AboveEditor),
821 };
822 ExtensionUiProxy::set_widget(&slot.key, Some(&lines), placement)
823 }
824 ExtensionUiEvent::Dispose { key } => ExtensionUiProxy::set_widget(&key, None, None),
825 }
826}
827
828#[allow(clippy::too_many_lines)]
837pub(crate) async fn handle_command<H>(
838 command: &RpcCommand,
839 host: &H,
840 state: &ServerState,
841) -> Option<RpcResponse>
842where
843 H: RpcSessionHost + ?Sized,
844{
845 let id = command.id().map(str::to_owned);
846
847 match command {
848 RpcCommand::Prompt {
849 message,
850 images,
851 streaming_behavior,
852 ..
853 } => {
854 spawn_prompt(
857 id,
858 message.clone(),
859 images.clone().unwrap_or_default(),
860 *streaming_behavior,
861 host,
862 state.write_tx.clone(),
863 )
864 .await;
865 None
866 }
867
868 RpcCommand::Steer {
869 message, images, ..
870 } => match host
871 .steer(message.clone(), images.clone().unwrap_or_default())
872 .await
873 {
874 Ok(()) => Some(RpcResponse::ok(id, "steer")),
875 Err(e) => Some(RpcResponse::err(id, "steer", e)),
876 },
877
878 RpcCommand::FollowUp {
879 message, images, ..
880 } => match host
881 .follow_up(message.clone(), images.clone().unwrap_or_default())
882 .await
883 {
884 Ok(()) => Some(RpcResponse::ok(id, "follow_up")),
885 Err(e) => Some(RpcResponse::err(id, "follow_up", e)),
886 },
887
888 RpcCommand::Abort { .. } => {
889 host.abort().await;
890 Some(RpcResponse::ok(id, "abort"))
891 }
892
893 RpcCommand::NewSession { parent_session, .. } => {
894 match host.new_session(parent_session.clone()).await {
895 Ok(cancelled) => {
896 if !cancelled {
897 state.rebind(host).await;
898 }
899 Some(RpcResponse::ok_data(
900 id,
901 "new_session",
902 RpcResponseData::Cancelled { cancelled },
903 ))
904 }
905 Err(e) => Some(RpcResponse::err(id, "new_session", e)),
906 }
907 }
908
909 RpcCommand::GetState { .. } => {
910 let s = host.get_state().await;
911 Some(RpcResponse::ok_data(
912 id,
913 "get_state",
914 RpcResponseData::SessionState(s),
915 ))
916 }
917
918 RpcCommand::SetModel {
919 provider, model_id, ..
920 } => {
921 let models = host.get_available_models().await;
922 match models
923 .into_iter()
924 .find(|m| m.provider == *provider && m.id == *model_id)
925 {
926 Some(model) => match host.set_model(model.clone()).await {
927 Ok(()) => Some(RpcResponse::ok_data(
928 id,
929 "set_model",
930 RpcResponseData::Model(model),
931 )),
932 Err(e) => Some(RpcResponse::err(id, "set_model", e)),
933 },
934 None => Some(RpcResponse::err(
935 id,
936 "set_model",
937 format!("Model not found: {provider}/{model_id}"),
938 )),
939 }
940 }
941
942 RpcCommand::CycleModel { .. } => {
943 let r = host.cycle_model().await;
944 let data = r.map(|x| {
945 RpcResponseData::CycleModel(Some(CycleModelData {
946 model: x.model,
947 thinking_level: x.thinking_level,
948 is_scoped: x.is_scoped,
949 }))
950 });
951 Some(RpcResponse::ok_data(
952 id,
953 "cycle_model",
954 data.unwrap_or(RpcResponseData::CycleModel(None)),
955 ))
956 }
957
958 RpcCommand::GetAvailableModels { .. } => {
959 let models = host.get_available_models().await;
960 Some(RpcResponse::ok_data(
961 id,
962 "get_available_models",
963 RpcResponseData::AvailableModels { models },
964 ))
965 }
966
967 RpcCommand::SetThinkingLevel { level, .. } => {
968 if host.set_thinking_level(*level).await {
969 Some(RpcResponse::ok(id, "set_thinking_level"))
970 } else {
971 Some(RpcResponse::err(
972 id,
973 "set_thinking_level",
974 "Failed to persist thinking level change",
975 ))
976 }
977 }
978
979 RpcCommand::CycleThinkingLevel { .. } => {
980 let level = host.cycle_thinking_level().await;
981 let data = level.map(|l| {
982 RpcResponseData::CycleThinkingLevel(Some(CycleThinkingLevelData { level: l }))
983 });
984 Some(RpcResponse::ok_data(
985 id,
986 "cycle_thinking_level",
987 data.unwrap_or(RpcResponseData::CycleThinkingLevel(None)),
988 ))
989 }
990
991 RpcCommand::SetSteeringMode { mode, .. } => {
992 host.set_steering_mode(*mode).await;
993 Some(RpcResponse::ok(id, "set_steering_mode"))
994 }
995
996 RpcCommand::SetFollowUpMode { mode, .. } => {
997 host.set_follow_up_mode(*mode).await;
998 Some(RpcResponse::ok(id, "set_follow_up_mode"))
999 }
1000
1001 RpcCommand::Compact {
1002 custom_instructions,
1003 ..
1004 } => match host.compact(custom_instructions.clone()).await {
1005 Ok(result) => Some(RpcResponse::ok_data(
1006 id,
1007 "compact",
1008 RpcResponseData::Compaction(result),
1009 )),
1010 Err(e) => Some(RpcResponse::err(id, "compact", e)),
1011 },
1012
1013 RpcCommand::SetAutoCompaction { enabled, .. } => {
1014 host.set_auto_compaction(*enabled).await;
1015 Some(RpcResponse::ok(id, "set_auto_compaction"))
1016 }
1017
1018 RpcCommand::SetAutoRetry { enabled, .. } => {
1019 host.set_auto_retry(*enabled).await;
1020 Some(RpcResponse::ok(id, "set_auto_retry"))
1021 }
1022
1023 RpcCommand::AbortRetry { .. } => {
1024 host.abort_retry().await;
1025 Some(RpcResponse::ok(id, "abort_retry"))
1026 }
1027
1028 RpcCommand::Bash {
1029 command: cmd,
1030 exclude_from_context,
1031 ..
1032 } => match host.execute_bash(cmd.clone(), *exclude_from_context).await {
1033 Ok(result) => Some(RpcResponse::ok_data(
1034 id,
1035 "bash",
1036 RpcResponseData::Bash(result),
1037 )),
1038 Err(e) => Some(RpcResponse::err(id, "bash", e)),
1039 },
1040
1041 RpcCommand::AbortBash { .. } => {
1042 host.abort_bash().await;
1043 Some(RpcResponse::ok(id, "abort_bash"))
1044 }
1045
1046 RpcCommand::GetSessionStats { .. } => {
1047 let session_stats = host.get_session_stats().await;
1048 Some(RpcResponse::ok_data(
1049 id,
1050 "get_session_stats",
1051 RpcResponseData::SessionStats(session_stats),
1052 ))
1053 }
1054
1055 RpcCommand::ExportHtml { output_path, .. } => {
1056 match host.export_to_html(output_path.clone()).await {
1057 Ok(path) => Some(RpcResponse::ok_data(
1058 id,
1059 "export_html",
1060 RpcResponseData::ExportHtml { path },
1061 )),
1062 Err(e) => Some(RpcResponse::err(id, "export_html", e)),
1063 }
1064 }
1065
1066 RpcCommand::SwitchSession { session_path, .. } => {
1067 match host.switch_session(session_path.clone()).await {
1068 Ok(cancelled) => {
1069 if !cancelled {
1070 state.rebind(host).await;
1071 }
1072 Some(RpcResponse::ok_data(
1073 id,
1074 "switch_session",
1075 RpcResponseData::Cancelled { cancelled },
1076 ))
1077 }
1078 Err(e) => Some(RpcResponse::err(id, "switch_session", e)),
1079 }
1080 }
1081
1082 RpcCommand::Fork { entry_id, .. } => {
1083 match host.fork(entry_id.clone(), ForkPosition::Before).await {
1084 Ok(outcome) => {
1085 if !outcome.cancelled {
1086 state.rebind(host).await;
1087 }
1088 Some(RpcResponse::ok_data(
1089 id,
1090 "fork",
1091 RpcResponseData::Fork {
1092 text: outcome.selected_text.unwrap_or_default(),
1093 cancelled: outcome.cancelled,
1094 },
1095 ))
1096 }
1097 Err(e) => Some(RpcResponse::err(id, "fork", e)),
1098 }
1099 }
1100
1101 RpcCommand::Clone { .. } => {
1102 let leaf = host.get_leaf_id().await;
1103 match leaf {
1104 None => Some(RpcResponse::err(
1105 id,
1106 "clone",
1107 "Cannot clone session: no current entry selected",
1108 )),
1109 Some(leaf_id) => match host.fork(leaf_id, ForkPosition::At).await {
1110 Ok(outcome) => {
1111 if !outcome.cancelled {
1112 state.rebind(host).await;
1113 }
1114 Some(RpcResponse::ok_data(
1115 id,
1116 "clone",
1117 RpcResponseData::Cancelled {
1118 cancelled: outcome.cancelled,
1119 },
1120 ))
1121 }
1122 Err(e) => Some(RpcResponse::err(id, "clone", e)),
1123 },
1124 }
1125 }
1126
1127 RpcCommand::GetForkMessages { .. } => {
1128 let messages = host.get_fork_messages().await;
1129 Some(RpcResponse::ok_data(
1130 id,
1131 "get_fork_messages",
1132 RpcResponseData::ForkMessages { messages },
1133 ))
1134 }
1135
1136 RpcCommand::GetEntries { since, .. } => {
1137 let entries = host.get_entries().await;
1138 let filtered = if let Some(since_id) = since {
1139 match entries
1140 .iter()
1141 .position(|e| e.id() == Some(since_id.as_str()))
1142 {
1143 None => {
1144 return Some(RpcResponse::err(
1145 id,
1146 "get_entries",
1147 format!("Entry not found: {since_id}"),
1148 ));
1149 }
1150 Some(i) => entries.into_iter().skip(i + 1).collect::<Vec<_>>(),
1151 }
1152 } else {
1153 entries
1154 };
1155 let leaf_id = host.get_leaf_id().await;
1156 Some(RpcResponse::ok_data(
1157 id,
1158 "get_entries",
1159 RpcResponseData::Entries {
1160 entries: filtered,
1161 leaf_id,
1162 },
1163 ))
1164 }
1165
1166 RpcCommand::GetTree { .. } => {
1167 let tree = host.get_tree().await;
1168 let leaf_id = host.get_leaf_id().await;
1169 Some(RpcResponse::ok_data(
1170 id,
1171 "get_tree",
1172 RpcResponseData::Tree { tree, leaf_id },
1173 ))
1174 }
1175
1176 RpcCommand::GetLastAssistantText { .. } => {
1177 let text = host.get_last_assistant_text().await;
1178 Some(RpcResponse::ok_data(
1179 id,
1180 "get_last_assistant_text",
1181 RpcResponseData::LastAssistantText { text },
1182 ))
1183 }
1184
1185 RpcCommand::SetSessionName { name, .. } => {
1186 let trimmed = name.trim();
1187 if trimmed.is_empty() {
1188 return Some(RpcResponse::err(
1189 id,
1190 "set_session_name",
1191 "Session name cannot be empty",
1192 ));
1193 }
1194 match host.set_session_name(trimmed.to_owned()).await {
1195 Ok(()) => Some(RpcResponse::ok(id, "set_session_name")),
1196 Err(e) => Some(RpcResponse::err(id, "set_session_name", e)),
1197 }
1198 }
1199
1200 RpcCommand::GetMessages { .. } => {
1201 let messages = host.get_messages().await;
1202 Some(RpcResponse::ok_data(
1203 id,
1204 "get_messages",
1205 RpcResponseData::Messages { messages },
1206 ))
1207 }
1208
1209 RpcCommand::GetCommands { .. } => {
1210 let commands = host.get_commands().await;
1211 Some(RpcResponse::ok_data(
1212 id,
1213 "get_commands",
1214 RpcResponseData::Commands { commands },
1215 ))
1216 }
1217
1218 RpcCommand::Unknown {
1219 command_type: ct, ..
1220 } => Some(RpcResponse::err(id, ct, format!("Unknown command: {ct}"))),
1221 }
1222}
1223
1224async fn spawn_prompt<H>(
1235 id: Option<String>,
1236 message: String,
1237 images: Vec<ImageContent>,
1238 streaming_behavior: Option<StreamingBehavior>,
1239 host: &H,
1240 write_tx: mpsc::UnboundedSender<WriteMessage>,
1241) where
1242 H: RpcSessionHost + ?Sized,
1243{
1244 let preflight_succeeded = Arc::new(AtomicBool::new(false));
1245 let (preflight_tx, preflight_rx) = tokio::sync::oneshot::channel::<()>();
1246 let preflight_notify = Arc::new(Mutex::new(Some(preflight_tx)));
1247
1248 let success_flag = Arc::clone(&preflight_succeeded);
1249 let success_tx = write_tx.clone();
1250 let success_id = id.clone();
1251 let notify_on_preflight = Arc::clone(&preflight_notify);
1252 let preflight: PreflightCallback = Arc::new(move |did_succeed: bool| {
1253 if did_succeed
1254 && success_flag
1255 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
1256 .is_ok()
1257 {
1258 let response = RpcResponse::ok(success_id.clone(), "prompt");
1259 let _ = success_tx.send(WriteMessage::Line(to_jsonl(&response)));
1260 }
1261 if let Some(tx) = notify_on_preflight
1264 .lock()
1265 .unwrap_or_else(std::sync::PoisonError::into_inner)
1266 .take()
1267 {
1268 let _ = tx.send(());
1269 }
1270 });
1271
1272 let prompt_future = host.prompt(message, images, streaming_behavior, preflight);
1273
1274 let error_flag = Arc::clone(&preflight_succeeded);
1275 let error_tx = write_tx;
1276 let error_id = id;
1277 let notify_on_error = Arc::clone(&preflight_notify);
1278
1279 tokio::spawn(async move {
1280 if let Err(msg) = prompt_future.await
1281 && !error_flag.load(Ordering::SeqCst)
1282 {
1283 let response = RpcResponse::err(error_id, "prompt", msg);
1284 let _ = error_tx.send(WriteMessage::Line(to_jsonl(&response)));
1285 }
1286 if let Some(tx) = notify_on_error
1289 .lock()
1290 .unwrap_or_else(std::sync::PoisonError::into_inner)
1291 .take()
1292 {
1293 let _ = tx.send(());
1294 }
1295 });
1296
1297 let _ = preflight_rx.await;
1298}
1299
1300#[derive(Debug, PartialEq, Eq)]
1306pub enum LineOutcome {
1307 Done,
1309 Shutdown,
1311}
1312
1313pub(crate) async fn process_input_line<H>(line: &str, host: &H, state: &ServerState) -> LineOutcome
1315where
1316 H: RpcSessionHost + ?Sized,
1317{
1318 let parsed: Value = match serde_json::from_str(line) {
1319 Ok(v) => v,
1320 Err(e) => {
1321 let response = RpcResponse::err(None, "parse", format!("Failed to parse command: {e}"));
1322 state.enqueue(to_jsonl(&response));
1323 return LineOutcome::Done;
1324 }
1325 };
1326
1327 if parsed.get("type").and_then(Value::as_str) == Some("extension_ui_response") {
1329 if let Ok(ui_resp) = serde_json::from_value::<RpcExtensionUiResponse>(parsed.clone()) {
1330 let _ = state.proxy.route_response(ui_resp);
1331 }
1332 return LineOutcome::Done;
1333 }
1334
1335 let command = match RpcCommand::parse_value(&parsed) {
1336 Ok(command) => command,
1337 Err(error) => {
1338 let response = RpcResponse::err(
1339 error.id,
1340 "parse",
1341 format!("Failed to parse command: {}", error.message),
1342 );
1343 state.enqueue(to_jsonl(&response));
1344 return LineOutcome::Done;
1345 }
1346 };
1347
1348 if let Some(resp) = handle_command(&command, host, state).await {
1349 state.enqueue(to_jsonl(&resp));
1350 }
1351
1352 if state.shutdown_requested.load(Ordering::SeqCst) {
1353 return LineOutcome::Shutdown;
1354 }
1355 LineOutcome::Done
1356}
1357
1358enum LineMsg {
1363 Line(String),
1364 ReadError(String),
1365 Eof,
1366}
1367
1368async fn stdin_reader<R>(input: R, tx: mpsc::Sender<LineMsg>)
1369where
1370 R: AsyncRead + Unpin + Send + 'static,
1371{
1372 let mut reader = JsonlLineReader::new(input);
1373 loop {
1374 match reader.next_line().await {
1375 Ok(Some(line)) => {
1376 if tx.send(LineMsg::Line(line)).await.is_err() {
1377 break;
1378 }
1379 }
1380 Ok(None) => {
1381 let _ = tx.send(LineMsg::Eof).await;
1382 break;
1383 }
1384 Err(error) => {
1385 let _ = tx.send(LineMsg::ReadError(error.to_string())).await;
1386 break;
1387 }
1388 }
1389 }
1390}
1391
1392pub async fn run_rpc_loop<H, R>(host: H, sink: Arc<dyn RpcSink>, input: R) -> i32
1404where
1405 H: RpcSessionHost,
1406 R: AsyncRead + Unpin + Send + 'static,
1407{
1408 let (write_tx, write_rx) = mpsc::unbounded_channel::<WriteMessage>();
1409 let state = Arc::new(ServerState::new(
1410 Arc::clone(&sink),
1411 write_tx,
1412 ExtensionUiProxy::new(),
1413 ));
1414 let writer_task = tokio::spawn(writer_actor(write_rx, Arc::clone(&sink)));
1415
1416 let rebind_signal = Arc::clone(&state.signal);
1417 let rebind_flag = Arc::clone(&state.needs_rebind);
1418 host.set_rebind(Some(Arc::new(move || {
1419 let signal = Arc::clone(&rebind_signal);
1420 let flag = Arc::clone(&rebind_flag);
1421 Box::pin(async move {
1422 flag.store(true, Ordering::SeqCst);
1423 signal.notify_one();
1424 })
1425 })));
1426
1427 state.rebind(&host).await;
1428
1429 let (line_tx, mut line_rx) = mpsc::channel::<LineMsg>(64);
1430 tokio::spawn(stdin_reader(input, line_tx));
1431
1432 let exit_code = loop {
1433 tokio::select! {
1434 biased;
1435 () = state.signal.notified() => {
1436 if state.shutdown_requested.load(Ordering::SeqCst) {
1437 break 0;
1438 }
1439 if state.needs_rebind.swap(false, Ordering::SeqCst) {
1440 state.rebind(&host).await;
1441 }
1442 }
1443 msg = line_rx.recv() => {
1444 match msg {
1445 Some(LineMsg::Line(line)) => {
1446 let outcome = process_input_line(&line, &host, &state).await;
1447 state.wait_for_output().await;
1448 if outcome == LineOutcome::Shutdown {
1449 break 0;
1450 }
1451 }
1452 Some(LineMsg::ReadError(error)) => {
1453 let response = RpcResponse::err(
1454 None,
1455 "transport",
1456 format!("Failed to read stdin: {error}"),
1457 );
1458 state.enqueue(to_jsonl(&response));
1459 break 1;
1460 }
1461 Some(LineMsg::Eof) | None => break 0,
1462 }
1463 }
1464 }
1465 };
1466
1467 state.cleanup(&host, exit_code).await;
1468 writer_task.abort();
1469 exit_code
1470}
1471
1472pub async fn run_rpc_mode<H>(host: H) -> i32
1481where
1482 H: RpcSessionHost,
1483{
1484 let _ = output_guard_mod::take_over_stdout();
1485 let sink: Arc<dyn RpcSink> = Arc::new(OutputGuardSink);
1486
1487 let (write_tx, write_rx) = mpsc::unbounded_channel::<WriteMessage>();
1488 let state = Arc::new(ServerState::new(
1489 Arc::clone(&sink),
1490 write_tx,
1491 ExtensionUiProxy::new(),
1492 ));
1493 let writer_task = tokio::spawn(writer_actor(write_rx, Arc::clone(&sink)));
1494
1495 let rs = Arc::clone(&state.signal);
1496 let rf = Arc::clone(&state.needs_rebind);
1497 host.set_rebind(Some(Arc::new(move || {
1498 let s = Arc::clone(&rs);
1499 let f = Arc::clone(&rf);
1500 Box::pin(async move {
1501 f.store(true, Ordering::SeqCst);
1502 s.notify_one();
1503 })
1504 })));
1505 state.rebind(&host).await;
1506
1507 let (signal_tx, mut signal_rx) = mpsc::channel::<i32>(1);
1508 spawn_signal_handlers(signal_tx);
1509
1510 let stdin = tokio::io::stdin();
1511 let (line_tx, mut line_rx) = mpsc::channel::<LineMsg>(64);
1512 tokio::spawn(stdin_reader(stdin, line_tx));
1513
1514 let exit_code = loop {
1515 tokio::select! {
1516 biased;
1517 code = signal_rx.recv() => {
1518 break code.unwrap_or(0);
1519 }
1520 () = state.signal.notified() => {
1521 if state.shutdown_requested.load(Ordering::SeqCst) {
1522 break 0;
1523 }
1524 if state.needs_rebind.swap(false, Ordering::SeqCst) {
1525 state.rebind(&host).await;
1526 }
1527 }
1528 msg = line_rx.recv() => {
1529 match msg {
1530 Some(LineMsg::Line(line)) => {
1531 let outcome = process_input_line(&line, &host, &state).await;
1532 state.wait_for_output().await;
1533 if outcome == LineOutcome::Shutdown {
1534 break 0;
1535 }
1536 }
1537 Some(LineMsg::ReadError(error)) => {
1538 let response = RpcResponse::err(
1539 None,
1540 "transport",
1541 format!("Failed to read stdin: {error}"),
1542 );
1543 state.enqueue(to_jsonl(&response));
1544 break 1;
1545 }
1546 Some(LineMsg::Eof) | None => break 0,
1547 }
1548 }
1549 }
1550 };
1551
1552 state.cleanup(&host, exit_code).await;
1553 writer_task.abort();
1554 output_guard_mod::restore_stdout();
1555 exit_code
1556}
1557
1558async fn writer_actor(mut write_rx: mpsc::UnboundedReceiver<WriteMessage>, sink: Arc<dyn RpcSink>) {
1559 while let Some(message) = write_rx.recv().await {
1560 match message {
1561 WriteMessage::Line(line) => {
1562 let _ = sink.write_stdout(line).await;
1563 }
1564 WriteMessage::Drain(done) => {
1565 let _ = sink.backpressure().await;
1566 let _ = done.send(());
1567 }
1568 }
1569 }
1570}
1571
1572fn spawn_signal_handlers(tx: mpsc::Sender<i32>) {
1574 #[cfg(unix)]
1575 {
1576 {
1577 let tx = tx.clone();
1578 tokio::spawn(async move {
1579 use tokio::signal::unix::{SignalKind, signal};
1580 if let Ok(mut sig) = signal(SignalKind::terminate()) {
1581 sig.recv().await;
1582 let _ = tx.send(143).await;
1583 }
1584 });
1585 }
1586 tokio::spawn(async move {
1587 use tokio::signal::unix::{SignalKind, signal};
1588 if let Ok(mut sig) = signal(SignalKind::hangup()) {
1589 sig.recv().await;
1590 let _ = tx.send(129).await;
1592 }
1593 });
1594 }
1595 #[cfg(not(unix))]
1596 {
1597 let _ = tx;
1598 }
1599}
1600
1601#[cfg(test)]
1606#[allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
1607mod tests {
1608 use super::*;
1609 use crate::core::agent_session::extension::ExtensionBindings;
1610 use crate::core::agent_session_runtime::{ForkOutcome, ForkPosition};
1611 use crate::core::compaction::CompactionResult;
1612 use crate::core::sessions::SessionEntry;
1613 use crate::modes::rpc::types::{
1614 RpcSessionState, RpcSessionTreeNode, RpcSlashCommand, RpcSlashCommandSource, RpcSourceInfo,
1615 RpcSourceOrigin, RpcSourceScope, SessionStats, SessionStatsTokens,
1616 };
1617 use pi_agent::QueueMode;
1618 use pi_ai::{ImageContent, Model, ModelThinkingLevel};
1619 use pi_ext::client::HostClient;
1620 use pi_ext::protocol::{Frame, FrameKind, HelloAck, Method, decode_frame_str, encode_frame};
1621 use std::task::{Context, Poll};
1622 use tokio::io::ReadBuf;
1623 use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream};
1624
1625 #[derive(Clone)]
1630 struct FakeConfig {
1631 state: RpcSessionState,
1632 models: Vec<Model>,
1633 commands: Vec<RpcSlashCommand>,
1634 cycle_model_result: Option<ModelCycleResult>,
1635 cycle_thinking_result: Option<ModelThinkingLevel>,
1636 set_thinking_result: bool,
1637 compact_result: Option<Result<CompactionResult, String>>,
1638 bash_result: Option<Result<BashResult, String>>,
1639 fork_outcome: Result<ForkOutcome, String>,
1640 leaf_id: Option<String>,
1641 prompt_error: Option<String>,
1642 session_op_cancelled: bool,
1643 }
1644
1645 impl Default for FakeConfig {
1646 fn default() -> Self {
1647 Self {
1648 state: test_state(),
1649 models: vec![],
1650 commands: vec![],
1651 cycle_model_result: None,
1652 cycle_thinking_result: None,
1653 set_thinking_result: true,
1654 compact_result: None,
1655 bash_result: None,
1656 fork_outcome: Ok(ForkOutcome::default()),
1657 leaf_id: Some("leaf1".into()),
1658 prompt_error: None,
1659 session_op_cancelled: false,
1660 }
1661 }
1662 }
1663
1664 fn test_state() -> RpcSessionState {
1665 RpcSessionState {
1666 model: None,
1667 thinking_level: ModelThinkingLevel::Medium,
1668 is_streaming: false,
1669 is_compacting: false,
1670 steering_mode: QueueMode::All,
1671 follow_up_mode: QueueMode::OneAtATime,
1672 session_file: None,
1673 session_id: "test-session".into(),
1674 session_name: None,
1675 auto_compaction_enabled: true,
1676 message_count: 0,
1677 pending_message_count: 0,
1678 }
1679 }
1680
1681 fn test_stats() -> SessionStats {
1682 SessionStats {
1683 session_file: None,
1684 session_id: "test-session".into(),
1685 user_messages: 0,
1686 assistant_messages: 0,
1687 tool_calls: 0,
1688 tool_results: 0,
1689 total_messages: 0,
1690 tokens: SessionStatsTokens {
1691 input: 0,
1692 output: 0,
1693 cache_read: 0,
1694 cache_write: 0,
1695 total: 0,
1696 },
1697 cost: 0.0,
1698 context_usage: None,
1699 }
1700 }
1701
1702 struct FailingInput;
1703
1704 impl AsyncRead for FailingInput {
1705 fn poll_read(
1706 self: Pin<&mut Self>,
1707 _cx: &mut Context<'_>,
1708 _buf: &mut ReadBuf<'_>,
1709 ) -> Poll<io::Result<()>> {
1710 Poll::Ready(Err(io::Error::new(
1711 io::ErrorKind::ConnectionReset,
1712 "stdin transport failed",
1713 )))
1714 }
1715 }
1716
1717 #[derive(Clone)]
1718 struct FakeRpcHost {
1719 cfg: Arc<Mutex<FakeConfig>>,
1720 calls: Arc<Mutex<Vec<String>>>,
1721 disposed: Arc<AtomicBool>,
1722 events_tx: Arc<Mutex<Option<tokio::sync::mpsc::UnboundedSender<AgentSessionEvent>>>>,
1723 bindings: Arc<Mutex<Option<ExtensionBindings>>>,
1724 extension_runner: Arc<Mutex<Option<Arc<HostExtensionRunner>>>>,
1725 }
1726
1727 impl FakeRpcHost {
1728 fn new(cfg: FakeConfig) -> Self {
1729 Self {
1730 cfg: Arc::new(Mutex::new(cfg)),
1731 calls: Arc::new(Mutex::new(Vec::new())),
1732 disposed: Arc::new(AtomicBool::new(false)),
1733 events_tx: Arc::new(Mutex::new(None)),
1734 bindings: Arc::new(Mutex::new(None)),
1735 extension_runner: Arc::new(Mutex::new(None)),
1736 }
1737 }
1738
1739 fn set_extension_runner(&self, runner: Arc<HostExtensionRunner>) {
1740 *self.extension_runner.lock().unwrap() = Some(runner);
1741 }
1742 fn rec(&self, name: &str) {
1743 self.calls.lock().unwrap().push(name.to_owned());
1744 }
1745 }
1746
1747 impl RpcSessionHost for FakeRpcHost {
1748 fn prompt(
1749 &self,
1750 _msg: String,
1751 _img: Vec<ImageContent>,
1752 _sb: Option<StreamingBehavior>,
1753 preflight: PreflightCallback,
1754 ) -> BoxFuture<'static, Result<(), String>> {
1755 self.rec("prompt");
1756 let err = self.cfg.lock().unwrap().prompt_error.clone();
1757 let events_tx = Arc::clone(&self.events_tx);
1758 Box::pin(async move {
1759 if let Some(e) = err {
1760 preflight(false);
1761 Err(e)
1762 } else {
1763 preflight(true);
1764 if let Some(tx) = events_tx.lock().unwrap().as_ref() {
1766 let _ = tx.send(AgentSessionEvent::TurnStart);
1767 }
1768 Ok(())
1769 }
1770 })
1771 }
1772 fn steer(
1773 &self,
1774 _m: String,
1775 _i: Vec<ImageContent>,
1776 ) -> BoxFuture<'static, Result<(), String>> {
1777 self.rec("steer");
1778 Box::pin(async { Ok(()) })
1779 }
1780 fn follow_up(
1781 &self,
1782 _m: String,
1783 _i: Vec<ImageContent>,
1784 ) -> BoxFuture<'static, Result<(), String>> {
1785 self.rec("follow_up");
1786 Box::pin(async { Ok(()) })
1787 }
1788 fn abort(&self) -> BoxFuture<'static, ()> {
1789 self.rec("abort");
1790 Box::pin(async {})
1791 }
1792 fn get_state(&self) -> BoxFuture<'static, RpcSessionState> {
1793 self.rec("get_state");
1794 let cfg = Arc::clone(&self.cfg);
1795 Box::pin(async move { cfg.lock().unwrap().state.clone() })
1796 }
1797 fn get_available_models(&self) -> BoxFuture<'static, Vec<Model>> {
1798 self.rec("get_available_models");
1799 let cfg = Arc::clone(&self.cfg);
1800 Box::pin(async move { cfg.lock().unwrap().models.clone() })
1801 }
1802 fn set_model(&self, _m: Model) -> BoxFuture<'static, Result<(), String>> {
1803 self.rec("set_model");
1804 Box::pin(async { Ok(()) })
1805 }
1806 fn cycle_model(&self) -> BoxFuture<'static, Option<ModelCycleResult>> {
1807 self.rec("cycle_model");
1808 let cfg = Arc::clone(&self.cfg);
1809 Box::pin(async move { cfg.lock().unwrap().cycle_model_result.clone() })
1810 }
1811 fn set_thinking_level(&self, _l: ModelThinkingLevel) -> BoxFuture<'static, bool> {
1812 self.rec("set_thinking_level");
1813 let cfg = Arc::clone(&self.cfg);
1814 Box::pin(async move { cfg.lock().unwrap().set_thinking_result })
1815 }
1816 fn cycle_thinking_level(&self) -> BoxFuture<'static, Option<ModelThinkingLevel>> {
1817 self.rec("cycle_thinking_level");
1818 let cfg = Arc::clone(&self.cfg);
1819 Box::pin(async move { cfg.lock().unwrap().cycle_thinking_result })
1820 }
1821 fn set_steering_mode(&self, _m: QueueMode) -> BoxFuture<'static, ()> {
1822 self.rec("set_steering_mode");
1823 Box::pin(async {})
1824 }
1825 fn set_follow_up_mode(&self, _m: QueueMode) -> BoxFuture<'static, ()> {
1826 self.rec("set_follow_up_mode");
1827 Box::pin(async {})
1828 }
1829 fn compact(
1830 &self,
1831 _ci: Option<String>,
1832 ) -> BoxFuture<'static, Result<CompactionResult, String>> {
1833 self.rec("compact");
1834 let cfg = Arc::clone(&self.cfg);
1835 Box::pin(async move {
1836 cfg.lock()
1837 .unwrap()
1838 .compact_result
1839 .clone()
1840 .unwrap_or(Ok(test_compaction_result()))
1841 })
1842 }
1843 fn set_auto_compaction(&self, _e: bool) -> BoxFuture<'static, ()> {
1844 self.rec("set_auto_compaction");
1845 Box::pin(async {})
1846 }
1847 fn set_auto_retry(&self, _e: bool) -> BoxFuture<'static, ()> {
1848 self.rec("set_auto_retry");
1849 Box::pin(async {})
1850 }
1851 fn abort_retry(&self) -> BoxFuture<'static, ()> {
1852 self.rec("abort_retry");
1853 Box::pin(async {})
1854 }
1855 fn execute_bash(
1856 &self,
1857 _c: String,
1858 _e: Option<bool>,
1859 ) -> BoxFuture<'static, Result<BashResult, String>> {
1860 self.rec("bash");
1861 let cfg = Arc::clone(&self.cfg);
1862 Box::pin(async move {
1863 cfg.lock()
1864 .unwrap()
1865 .bash_result
1866 .clone()
1867 .unwrap_or(Ok(test_bash_result()))
1868 })
1869 }
1870 fn abort_bash(&self) -> BoxFuture<'static, ()> {
1871 self.rec("abort_bash");
1872 Box::pin(async {})
1873 }
1874 fn get_session_stats(&self) -> BoxFuture<'static, SessionStats> {
1875 self.rec("get_session_stats");
1876 Box::pin(async { test_stats() })
1877 }
1878 fn export_to_html(&self, _p: Option<String>) -> BoxFuture<'static, Result<String, String>> {
1879 self.rec("export_html");
1880 Box::pin(async { Ok("/tmp/out.html".into()) })
1881 }
1882 fn set_session_name(&self, _n: String) -> BoxFuture<'static, Result<(), String>> {
1883 self.rec("set_session_name");
1884 Box::pin(async { Ok(()) })
1885 }
1886 fn new_session(&self, _p: Option<String>) -> BoxFuture<'static, Result<bool, String>> {
1887 self.rec("new_session");
1888 let cfg = Arc::clone(&self.cfg);
1889 Box::pin(async move { Ok(cfg.lock().unwrap().session_op_cancelled) })
1890 }
1891 fn switch_session(&self, _p: String) -> BoxFuture<'static, Result<bool, String>> {
1892 self.rec("switch_session");
1893 let cfg = Arc::clone(&self.cfg);
1894 Box::pin(async move { Ok(cfg.lock().unwrap().session_op_cancelled) })
1895 }
1896 fn fork(
1897 &self,
1898 _e: String,
1899 _p: ForkPosition,
1900 ) -> BoxFuture<'static, Result<ForkOutcome, String>> {
1901 self.rec("fork");
1902 let cfg = Arc::clone(&self.cfg);
1903 Box::pin(async move { cfg.lock().unwrap().fork_outcome.clone() })
1904 }
1905 fn get_entries(&self) -> BoxFuture<'static, Vec<SessionEntry>> {
1906 self.rec("get_entries");
1907 Box::pin(async { vec![] })
1908 }
1909 fn get_leaf_id(&self) -> BoxFuture<'static, Option<String>> {
1910 self.rec("get_leaf_id");
1911 let cfg = Arc::clone(&self.cfg);
1912 Box::pin(async move { cfg.lock().unwrap().leaf_id.clone() })
1913 }
1914 fn get_tree(&self) -> BoxFuture<'static, Vec<RpcSessionTreeNode>> {
1915 self.rec("get_tree");
1916 Box::pin(async { vec![] })
1917 }
1918 fn get_fork_messages(&self) -> BoxFuture<'static, Vec<ForkMessage>> {
1919 self.rec("get_fork_messages");
1920 Box::pin(async { vec![] })
1921 }
1922 fn get_last_assistant_text(&self) -> BoxFuture<'static, Option<String>> {
1923 self.rec("get_last_assistant_text");
1924 Box::pin(async { None })
1925 }
1926 fn get_messages(&self) -> BoxFuture<'static, Vec<AgentMessage>> {
1927 self.rec("get_messages");
1928 Box::pin(async { vec![] })
1929 }
1930 fn get_commands(&self) -> BoxFuture<'static, Vec<RpcSlashCommand>> {
1931 self.rec("get_commands");
1932 let cfg = Arc::clone(&self.cfg);
1933 Box::pin(async move { cfg.lock().unwrap().commands.clone() })
1934 }
1935 fn subscribe(
1936 &self,
1937 listener: Arc<dyn Fn(&AgentSessionEvent) + Send + Sync>,
1938 ) -> Box<dyn Fn() + Send + Sync> {
1939 self.rec("subscribe");
1940 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<AgentSessionEvent>();
1941 *self.events_tx.lock().unwrap() = Some(tx);
1942 tokio::spawn(async move {
1943 while let Some(event) = rx.recv().await {
1944 listener(&event);
1945 }
1946 });
1947 Box::new(|| {})
1948 }
1949 fn register_backpressure_hook(
1950 &self,
1951 _hook: Arc<dyn Fn() -> BoxFuture<'static, ()> + Send + Sync>,
1952 ) -> Box<dyn Fn() + Send + Sync> {
1953 self.rec("register_backpressure_hook");
1954 Box::new(|| {})
1955 }
1956 fn bind_extensions_rpc(
1957 &self,
1958 bindings: ExtensionBindings,
1959 ) -> BoxFuture<'static, Result<(), String>> {
1960 self.rec("bind_extensions_rpc");
1961 *self.bindings.lock().unwrap() = Some(bindings);
1962 Box::pin(async { Ok(()) })
1963 }
1964 fn host_extension_runner(&self) -> Option<Arc<HostExtensionRunner>> {
1965 self.extension_runner.lock().unwrap().clone()
1966 }
1967 fn dispose(&self) -> BoxFuture<'static, ()> {
1968 self.rec("dispose");
1969 self.disposed.store(true, Ordering::SeqCst);
1970 Box::pin(async {})
1971 }
1972 fn set_rebind(&self, _cb: Option<RebindCallback>) {
1973 self.rec("set_rebind");
1974 }
1975 }
1976 fn test_compaction_result() -> CompactionResult {
1977 CompactionResult {
1978 summary: "Summary".into(),
1979 first_kept_entry_id: "entry1".into(),
1980 tokens_before: 1000,
1981 estimated_tokens_after: Some(500),
1982 details: None,
1983 from_hook: None,
1984 }
1985 }
1986 fn test_bash_result() -> BashResult {
1987 BashResult {
1988 output: "done".into(),
1989 exit_code: Some(0),
1990 cancelled: false,
1991 truncated: false,
1992 full_output_path: None,
1993 }
1994 }
1995 fn make_state(
2000 sink: BufferSink,
2001 ) -> (
2002 Arc<ServerState>,
2003 BufferSink,
2004 mpsc::UnboundedReceiver<WriteMessage>,
2005 ) {
2006 let s = sink.clone();
2007 let (write_tx, write_rx) = mpsc::unbounded_channel::<WriteMessage>();
2008 let state = Arc::new(ServerState::new(
2009 Arc::new(sink) as Arc<dyn RpcSink>,
2010 write_tx,
2011 ExtensionUiProxy::new(),
2012 ));
2013 (state, s, write_rx)
2014 }
2015
2016 async fn drain(sink: &BufferSink, write_rx: &mut mpsc::UnboundedReceiver<WriteMessage>) {
2018 while let Ok(message) = write_rx.try_recv() {
2019 match message {
2020 WriteMessage::Line(line) => {
2021 let _ = sink.write_stdout(line).await;
2022 }
2023 WriteMessage::Drain(done) => {
2024 let _ = sink.backpressure().await;
2025 let _ = done.send(());
2026 }
2027 }
2028 }
2029 }
2030
2031 async fn dispatch(cmd_json: &str, cfg: FakeConfig) -> (Value, BufferSink) {
2032 let host = FakeRpcHost::new(cfg);
2033 let sink = BufferSink::new();
2034 let (state, sink_clone, mut write_rx) = make_state(sink);
2035 process_input_line(cmd_json, &host, &state).await;
2036 drain(&sink_clone, &mut write_rx).await;
2037 let lines = sink_clone.stdout_lines();
2038 assert!(!lines.is_empty(), "expected at least one response line");
2039 let resp: Value = serde_json::from_str(&lines[0]).unwrap();
2040 (resp, sink_clone)
2041 }
2042
2043 async fn dispatch_no_response(
2044 cmd_json: &str,
2045 cfg: FakeConfig,
2046 ) -> (BufferSink, mpsc::UnboundedReceiver<WriteMessage>) {
2047 let host = FakeRpcHost::new(cfg);
2048 let sink = BufferSink::new();
2049 let (state, sink_clone, mut write_rx) = make_state(sink);
2050 process_input_line(cmd_json, &host, &state).await;
2051 drain(&sink_clone, &mut write_rx).await;
2052 (sink_clone, write_rx)
2053 }
2054
2055 #[tokio::test]
2060 async fn unknown_command_echoes_id_and_type() {
2061 let (resp, _) = dispatch(
2062 r#"{"type":"totally_unknown","id":"abc","foo":"bar"}"#,
2063 FakeConfig::default(),
2064 )
2065 .await;
2066 assert_eq!(resp["type"], "response");
2067 assert_eq!(resp["id"], "abc");
2068 assert_eq!(resp["command"], "totally_unknown");
2069 assert_eq!(resp["success"], false);
2070 assert_eq!(resp["error"], "Unknown command: totally_unknown");
2071 }
2072
2073 #[tokio::test]
2078 async fn malformed_json_parse_error_no_id() {
2079 let (resp, _) = dispatch("{not valid json", FakeConfig::default()).await;
2080 assert_eq!(resp["command"], "parse");
2081 assert_eq!(resp["success"], false);
2082 assert!(resp.get("id").is_none() || resp["id"].is_null());
2083 assert!(
2084 resp["error"]
2085 .as_str()
2086 .unwrap()
2087 .starts_with("Failed to parse")
2088 );
2089 }
2090
2091 #[tokio::test]
2092 async fn field_validation_error_echoes_valid_id() {
2093 let (response, _) = dispatch(
2094 r#"{"type":"bash","id":"req-17","command":"true","excludeFromContext":"yes"}"#,
2095 FakeConfig::default(),
2096 )
2097 .await;
2098 assert_eq!(response["id"], "req-17");
2099 assert_eq!(response["command"], "parse");
2100 assert_eq!(response["success"], false);
2101 assert!(
2102 response["error"]
2103 .as_str()
2104 .unwrap()
2105 .contains("excludeFromContext")
2106 );
2107 }
2108
2109 #[tokio::test]
2114 async fn prompt_emits_success_once() {
2115 let (sink, mut write_rx) = dispatch_no_response(
2116 r#"{"type":"prompt","id":"p1","message":"hello"}"#,
2117 FakeConfig::default(),
2118 )
2119 .await;
2120 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2121 drain(&sink, &mut write_rx).await;
2122 let lines = sink.stdout_lines();
2123 assert_eq!(lines.len(), 1, "exactly one success response");
2124 let resp: Value = serde_json::from_str(&lines[0]).unwrap();
2125 assert_eq!(resp["command"], "prompt");
2126 assert_eq!(resp["success"], true);
2127 }
2128
2129 #[tokio::test]
2130 async fn prompt_emits_error() {
2131 let cfg = FakeConfig {
2132 prompt_error: Some("No model".into()),
2133 ..FakeConfig::default()
2134 };
2135 let (sink, mut write_rx) =
2136 dispatch_no_response(r#"{"type":"prompt","id":"p2","message":"hi"}"#, cfg).await;
2137 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2138 drain(&sink, &mut write_rx).await;
2139 let lines = sink.stdout_lines();
2140 assert_eq!(lines.len(), 1);
2141 let resp: Value = serde_json::from_str(&lines[0]).unwrap();
2142 assert_eq!(resp["success"], false);
2143 assert_eq!(resp["error"], "No model");
2144 }
2145
2146 #[tokio::test]
2151 async fn steer_success() {
2152 let (r, _) = dispatch(
2153 r#"{"type":"steer","id":"s1","message":"left"}"#,
2154 FakeConfig::default(),
2155 )
2156 .await;
2157 assert_eq!(r["command"], "steer");
2158 assert_eq!(r["success"], true);
2159 }
2160
2161 #[tokio::test]
2162 async fn follow_up_success() {
2163 let (r, _) = dispatch(
2164 r#"{"type":"follow_up","id":"f1","message":"next"}"#,
2165 FakeConfig::default(),
2166 )
2167 .await;
2168 assert_eq!(r["command"], "follow_up");
2169 }
2170
2171 #[tokio::test]
2172 async fn abort_success() {
2173 let (r, _) = dispatch(r#"{"type":"abort","id":"a1"}"#, FakeConfig::default()).await;
2174 assert_eq!(r["command"], "abort");
2175 }
2176
2177 #[tokio::test]
2178 async fn set_thinking_level_success() {
2179 let (r, _) = dispatch(
2180 r#"{"type":"set_thinking_level","id":"t1","level":"high"}"#,
2181 FakeConfig::default(),
2182 )
2183 .await;
2184 assert_eq!(r["command"], "set_thinking_level");
2185 assert_eq!(r["success"], true);
2186 }
2187
2188 #[tokio::test]
2189 async fn set_thinking_level_uncommitted_returns_error() {
2190 let (r, _) = dispatch(
2191 r#"{"type":"set_thinking_level","id":"t2","level":"high"}"#,
2192 FakeConfig {
2193 set_thinking_result: false,
2194 ..FakeConfig::default()
2195 },
2196 )
2197 .await;
2198 assert_eq!(r["command"], "set_thinking_level");
2199 assert_eq!(r["success"], false);
2200 assert_eq!(r["error"], "Failed to persist thinking level change");
2201 }
2202
2203 #[tokio::test]
2204 async fn set_steering_mode_success() {
2205 let (r, _) = dispatch(
2206 r#"{"type":"set_steering_mode","id":"sm1","mode":"all"}"#,
2207 FakeConfig::default(),
2208 )
2209 .await;
2210 assert_eq!(r["command"], "set_steering_mode");
2211 }
2212
2213 #[tokio::test]
2214 async fn set_follow_up_mode_success() {
2215 let (r, _) = dispatch(
2216 r#"{"type":"set_follow_up_mode","id":"fm1","mode":"one-at-a-time"}"#,
2217 FakeConfig::default(),
2218 )
2219 .await;
2220 assert_eq!(r["command"], "set_follow_up_mode");
2221 }
2222
2223 #[tokio::test]
2224 async fn set_auto_compaction_success() {
2225 let (r, _) = dispatch(
2226 r#"{"type":"set_auto_compaction","id":"ac1","enabled":true}"#,
2227 FakeConfig::default(),
2228 )
2229 .await;
2230 assert_eq!(r["command"], "set_auto_compaction");
2231 }
2232
2233 #[tokio::test]
2234 async fn set_auto_retry_success() {
2235 let (r, _) = dispatch(
2236 r#"{"type":"set_auto_retry","id":"ar1","enabled":false}"#,
2237 FakeConfig::default(),
2238 )
2239 .await;
2240 assert_eq!(r["command"], "set_auto_retry");
2241 }
2242
2243 #[tokio::test]
2244 async fn abort_retry_success() {
2245 let (r, _) = dispatch(
2246 r#"{"type":"abort_retry","id":"abr1"}"#,
2247 FakeConfig::default(),
2248 )
2249 .await;
2250 assert_eq!(r["command"], "abort_retry");
2251 }
2252
2253 #[tokio::test]
2254 async fn abort_bash_success() {
2255 let (r, _) = dispatch(r#"{"type":"abort_bash","id":"ab1"}"#, FakeConfig::default()).await;
2256 assert_eq!(r["command"], "abort_bash");
2257 }
2258
2259 #[tokio::test]
2264 async fn get_state_returns_snapshot() {
2265 let (r, _) = dispatch(r#"{"type":"get_state","id":"gs1"}"#, FakeConfig::default()).await;
2266 assert_eq!(r["data"]["sessionId"], "test-session");
2267 assert_eq!(r["data"]["thinkingLevel"], "medium");
2268 }
2269
2270 #[tokio::test]
2271 async fn get_session_stats_returns_data() {
2272 let (r, _) = dispatch(
2273 r#"{"type":"get_session_stats","id":"sst1"}"#,
2274 FakeConfig::default(),
2275 )
2276 .await;
2277 assert_eq!(r["data"]["sessionId"], "test-session");
2278 }
2279
2280 #[tokio::test]
2281 async fn export_html_returns_path() {
2282 let (r, _) = dispatch(
2283 r#"{"type":"export_html","id":"eh1","outputPath":"/tmp/x.html"}"#,
2284 FakeConfig::default(),
2285 )
2286 .await;
2287 assert_eq!(r["data"]["path"], "/tmp/out.html");
2288 }
2289
2290 #[tokio::test]
2291 async fn get_last_assistant_text_null() {
2292 let (r, _) = dispatch(
2293 r#"{"type":"get_last_assistant_text","id":"lat1"}"#,
2294 FakeConfig::default(),
2295 )
2296 .await;
2297 assert!(r["data"]["text"].is_null());
2298 }
2299
2300 #[tokio::test]
2301 async fn get_messages_empty() {
2302 let (r, _) = dispatch(
2303 r#"{"type":"get_messages","id":"gm1"}"#,
2304 FakeConfig::default(),
2305 )
2306 .await;
2307 assert_eq!(r["data"]["messages"].as_array().unwrap().len(), 0);
2308 }
2309
2310 #[tokio::test]
2311 async fn get_commands_returns_complete_catalog() {
2312 let source_info = RpcSourceInfo {
2313 path: "/tmp/resource".into(),
2314 source: "test".into(),
2315 scope: RpcSourceScope::Temporary,
2316 origin: RpcSourceOrigin::TopLevel,
2317 base_dir: None,
2318 };
2319 let commands = [
2320 ("ext-command", RpcSlashCommandSource::Extension),
2321 ("deploy", RpcSlashCommandSource::Prompt),
2322 ("skill:review", RpcSlashCommandSource::Skill),
2323 ]
2324 .into_iter()
2325 .map(|(name, source)| RpcSlashCommand {
2326 name: name.into(),
2327 description: Some(format!("{name} description")),
2328 source,
2329 source_info: source_info.clone(),
2330 })
2331 .collect();
2332 let (response, _) = dispatch(
2333 r#"{"type":"get_commands","id":"gc1"}"#,
2334 FakeConfig {
2335 commands,
2336 ..FakeConfig::default()
2337 },
2338 )
2339 .await;
2340 let catalog = response["data"]["commands"].as_array().unwrap();
2341 assert_eq!(catalog.len(), 3);
2342 assert_eq!(catalog[0]["source"], "extension");
2343 assert_eq!(catalog[1]["source"], "prompt");
2344 assert_eq!(catalog[2]["name"], "skill:review");
2345 assert_eq!(catalog[2]["source"], "skill");
2346 }
2347
2348 #[tokio::test]
2349 async fn bash_returns_result() {
2350 let (r, _) = dispatch(
2351 r#"{"type":"bash","id":"b1","command":"echo hi"}"#,
2352 FakeConfig::default(),
2353 )
2354 .await;
2355 assert_eq!(r["data"]["output"], "done");
2356 }
2357
2358 #[tokio::test]
2359 async fn compact_returns_result() {
2360 let (r, _) = dispatch(r#"{"type":"compact","id":"c1"}"#, FakeConfig::default()).await;
2361 assert_eq!(r["data"]["summary"], "Summary");
2362 }
2363
2364 #[tokio::test]
2365 async fn cycle_model_null() {
2366 let (r, _) = dispatch(
2367 r#"{"type":"cycle_model","id":"cm1"}"#,
2368 FakeConfig::default(),
2369 )
2370 .await;
2371 assert!(r["data"].is_null());
2372 }
2373
2374 #[tokio::test]
2375 async fn cycle_thinking_null() {
2376 let (r, _) = dispatch(
2377 r#"{"type":"cycle_thinking_level","id":"ct1"}"#,
2378 FakeConfig::default(),
2379 )
2380 .await;
2381 assert!(r["data"].is_null());
2382 }
2383
2384 #[tokio::test]
2385 async fn get_available_models_empty() {
2386 let (r, _) = dispatch(
2387 r#"{"type":"get_available_models","id":"gam1"}"#,
2388 FakeConfig::default(),
2389 )
2390 .await;
2391 assert_eq!(r["data"]["models"].as_array().unwrap().len(), 0);
2392 }
2393
2394 #[tokio::test]
2395 async fn get_fork_messages_empty() {
2396 let (r, _) = dispatch(
2397 r#"{"type":"get_fork_messages","id":"gfm1"}"#,
2398 FakeConfig::default(),
2399 )
2400 .await;
2401 assert!(r["data"]["messages"].is_array());
2402 }
2403
2404 #[tokio::test]
2405 async fn get_tree_empty() {
2406 let (r, _) = dispatch(r#"{"type":"get_tree","id":"gt1"}"#, FakeConfig::default()).await;
2407 assert!(r["data"]["tree"].is_array());
2408 }
2409
2410 #[tokio::test]
2415 async fn set_model_not_found() {
2416 let (r, _) = dispatch(
2417 r#"{"type":"set_model","id":"sm1","provider":"openai","modelId":"gpt-999"}"#,
2418 FakeConfig::default(),
2419 )
2420 .await;
2421 assert_eq!(r["success"], false);
2422 assert_eq!(r["error"], "Model not found: openai/gpt-999");
2423 }
2424 #[tokio::test]
2425 async fn clone_no_leaf() {
2426 let cfg = FakeConfig {
2427 leaf_id: None,
2428 ..Default::default()
2429 };
2430 let (r, _) = dispatch(r#"{"type":"clone","id":"cl1"}"#, cfg).await;
2431 assert_eq!(
2432 r["error"],
2433 "Cannot clone session: no current entry selected"
2434 );
2435 }
2436
2437 #[tokio::test]
2438 async fn set_session_name_empty() {
2439 let (r, _) = dispatch(
2440 r#"{"type":"set_session_name","id":"ssn1","name":" "}"#,
2441 FakeConfig::default(),
2442 )
2443 .await;
2444 assert_eq!(r["error"], "Session name cannot be empty");
2445 }
2446
2447 #[tokio::test]
2448 async fn get_entries_since_not_found() {
2449 let (r, _) = dispatch(
2450 r#"{"type":"get_entries","id":"ge1","since":"nope"}"#,
2451 FakeConfig::default(),
2452 )
2453 .await;
2454 assert_eq!(r["error"], "Entry not found: nope");
2455 }
2456
2457 #[tokio::test]
2461 async fn new_session_cancelled() {
2462 let cfg = FakeConfig {
2463 session_op_cancelled: true,
2464 ..Default::default()
2465 };
2466 let (r, _) = dispatch(r#"{"type":"new_session","id":"ns1"}"#, cfg).await;
2467 assert_eq!(r["data"]["cancelled"], true);
2468 }
2469
2470 #[tokio::test]
2471 async fn fork_returns_text() {
2472 let cfg = FakeConfig {
2473 fork_outcome: Ok(ForkOutcome {
2474 cancelled: false,
2475 selected_text: Some("fork here".into()),
2476 }),
2477 ..Default::default()
2478 };
2479 let (r, _) = dispatch(r#"{"type":"fork","id":"fk1","entryId":"e1"}"#, cfg).await;
2480 assert_eq!(r["data"]["text"], "fork here");
2481 }
2482
2483 #[tokio::test]
2484 async fn clone_with_leaf() {
2485 let (r, host) = dispatch(r#"{"type":"clone","id":"cl2"}"#, FakeConfig::default()).await;
2486 let _ = r;
2487 let _ = host;
2488 }
2489
2490 #[tokio::test]
2491 async fn switch_session_success() {
2492 let (r, _) = dispatch(
2493 r#"{"type":"switch_session","id":"sw1","sessionPath":"/tmp/s.jsonl"}"#,
2494 FakeConfig::default(),
2495 )
2496 .await;
2497 assert_eq!(r["data"]["cancelled"], false);
2498 }
2499
2500 struct RpcHostPeer {
2505 read: BufReader<DuplexStream>,
2506 write: DuplexStream,
2507 }
2508
2509 impl RpcHostPeer {
2510 async fn read_frame(&mut self) -> Result<Frame, Box<dyn std::error::Error>> {
2511 let mut line = String::new();
2512 self.read.read_line(&mut line).await?;
2513 Ok(decode_frame_str(line.trim_end())?)
2514 }
2515
2516 async fn write_frame(&mut self, frame: &Frame) -> Result<(), Box<dyn std::error::Error>> {
2517 self.write.write_all(&encode_frame(frame)?).await?;
2518 self.write.flush().await?;
2519 Ok(())
2520 }
2521 }
2522
2523 async fn make_rpc_extension_runner()
2524 -> Result<(Arc<HostExtensionRunner>, RpcHostPeer), Box<dyn std::error::Error>> {
2525 let (client_stdout, host_stdout) = tokio::io::duplex(64 * 1024);
2526 let (host_stdin, client_stdin) = tokio::io::duplex(64 * 1024);
2527 let client = Arc::new(HostClient::connect_boxed(
2528 Box::new(client_stdin),
2529 Box::new(client_stdout),
2530 Box::new(tokio::io::empty()),
2531 None,
2532 ));
2533 let connect_client = Arc::clone(&client);
2534 let connect = tokio::spawn(async move {
2535 HostExtensionRunner::connect_with_cwd_and_trust(
2536 connect_client,
2537 Vec::new(),
2538 "/workspace",
2539 false,
2540 std::time::Duration::from_secs(1),
2541 )
2542 .await
2543 });
2544 let mut peer = RpcHostPeer {
2545 read: BufReader::new(host_stdin),
2546 write: host_stdout,
2547 };
2548 let hello = peer.read_frame().await?;
2549 peer.write_frame(&Frame::response(
2550 hello.id,
2551 Method::Hello,
2552 serde_json::to_value(HelloAck::local())?,
2553 ))
2554 .await?;
2555 let load = peer.read_frame().await?;
2556 assert_eq!(load.method, "extensions.load");
2557 peer.write_frame(&Frame::response(
2558 load.id,
2559 Method::Notify,
2560 serde_json::json!({
2561 "tools": [],
2562 "commands": [],
2563 "shortcuts": [],
2564 "flags": [],
2565 "renderers": [],
2566 "providers": [],
2567 "handlers": [],
2568 "errors": [],
2569 "terminalInput": false
2570 }),
2571 ))
2572 .await?;
2573 Ok((connect.await??, peer))
2574 }
2575
2576 #[tokio::test]
2577 async fn host_dialog_round_trips_through_rpc_stdout_and_stdin()
2578 -> Result<(), Box<dyn std::error::Error>> {
2579 let (runner, mut peer) = make_rpc_extension_runner().await?;
2580 let host = FakeRpcHost::new(FakeConfig::default());
2581 host.set_extension_runner(Arc::clone(&runner));
2582 let sink = BufferSink::new();
2583 let sink_arc = Arc::new(sink.clone()) as Arc<dyn RpcSink>;
2584 let (write_tx, write_rx) = mpsc::unbounded_channel::<WriteMessage>();
2585 let state = ServerState::new(sink_arc.clone(), write_tx, ExtensionUiProxy::new());
2586 let writer = tokio::spawn(writer_actor(write_rx, sink_arc));
2587 state.rebind(&host).await;
2588
2589 peer.write_frame(&Frame {
2590 id: 901,
2591 kind: FrameKind::Req,
2592 method: Method::Select.as_str().to_owned(),
2593 payload: serde_json::json!({
2594 "title": "Pick",
2595 "options": ["a", "b"],
2596 "timeoutMs": 1000
2597 }),
2598 })
2599 .await?;
2600
2601 let ui_request = tokio::time::timeout(std::time::Duration::from_secs(1), async {
2602 loop {
2603 state.wait_for_output().await;
2604 if let Some(line) = sink.stdout_lines().last().cloned()
2605 && serde_json::from_str::<Value>(&line)
2606 .ok()
2607 .and_then(|value| {
2608 value.get("type").and_then(Value::as_str).map(str::to_owned)
2609 })
2610 .as_deref()
2611 == Some("extension_ui_request")
2612 {
2613 break line;
2614 }
2615 tokio::task::yield_now().await;
2616 }
2617 })
2618 .await?;
2619 let request_json: Value = serde_json::from_str(&ui_request)?;
2620 assert_eq!(request_json["method"], "select");
2621 assert_eq!(request_json["title"], "Pick");
2622 let rpc_id = request_json["id"].as_str().ok_or("missing RPC UI id")?;
2623 let response = serde_json::json!({
2624 "type": "extension_ui_response",
2625 "id": rpc_id,
2626 "value": "b"
2627 })
2628 .to_string();
2629 assert_eq!(
2630 process_input_line(&response, &host, &state).await,
2631 LineOutcome::Done
2632 );
2633
2634 let host_response =
2635 tokio::time::timeout(std::time::Duration::from_secs(1), peer.read_frame()).await??;
2636 assert_eq!(host_response.kind, FrameKind::Res);
2637 assert_eq!(host_response.id, 901);
2638 assert_eq!(host_response.method, "select");
2639 assert_eq!(host_response.payload["value"], "b");
2640
2641 state.cleanup(&host, 0).await;
2642 writer.abort();
2643 runner.shutdown_once().await;
2644 Ok(())
2645 }
2646
2647 #[tokio::test]
2648 async fn ui_response_routes_to_proxy() {
2649 let host = FakeRpcHost::new(FakeConfig::default());
2650 let sink = BufferSink::new();
2651 let proxy = ExtensionUiProxy::new();
2652 let (req, rx) = proxy.create_dialog(|id| RpcExtensionUiRequest::Select {
2653 id: id.to_owned(),
2654 title: "Pick".into(),
2655 options: vec!["a".into()],
2656 timeout: None,
2657 });
2658 let pending_id = req.id().to_owned();
2659 let (write_tx, _write_rx) = mpsc::unbounded_channel::<WriteMessage>();
2660 let state = ServerState::new(Arc::new(sink.clone()) as Arc<dyn RpcSink>, write_tx, proxy);
2661 let resp_json =
2662 format!(r#"{{"type":"extension_ui_response","id":"{pending_id}","value":"picked"}}"#);
2663 process_input_line(&resp_json, &host, &state).await;
2664 assert!(sink.stdout_lines().is_empty());
2665 let resp = rx.await.unwrap();
2666 match resp {
2667 RpcExtensionUiResponse::Value { value, .. } => assert_eq!(value, "picked"),
2668 _ => panic!(),
2669 }
2670 }
2671
2672 #[tokio::test]
2673 async fn orphan_ui_response_dropped() {
2674 let host = FakeRpcHost::new(FakeConfig::default());
2675 let sink = BufferSink::new();
2676 let (state, _, _) = make_state(sink.clone());
2677 process_input_line(
2678 r#"{"type":"extension_ui_response","id":"orphan","value":"x"}"#,
2679 &host,
2680 &state,
2681 )
2682 .await;
2683 assert!(sink.stdout_lines().is_empty());
2684 }
2685
2686 #[tokio::test]
2691 async fn prompt_then_steer_ordering() {
2692 let host = FakeRpcHost::new(FakeConfig::default());
2693 let sink = BufferSink::new();
2694 let (state, sink_clone, mut write_rx) = make_state(sink);
2695 process_input_line(
2696 r#"{"type":"prompt","id":"q1","message":"first"}"#,
2697 &host,
2698 &state,
2699 )
2700 .await;
2701 process_input_line(
2702 r#"{"type":"steer","id":"q2","message":"second"}"#,
2703 &host,
2704 &state,
2705 )
2706 .await;
2707 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2708 drain(&sink_clone, &mut write_rx).await;
2709 let lines = sink_clone.stdout_lines();
2710 assert_eq!(lines.len(), 2);
2711 let r1: Value = serde_json::from_str(&lines[0]).unwrap();
2712 let r2: Value = serde_json::from_str(&lines[1]).unwrap();
2713 assert_eq!(r1["command"], "prompt");
2714 assert_eq!(r2["command"], "steer");
2715 }
2716 #[tokio::test]
2717 async fn prompt_response_precedes_agent_events() {
2718 let host = FakeRpcHost::new(FakeConfig::default());
2719 let sink = BufferSink::new();
2720 let (state, sink_clone, mut write_rx) = make_state(sink);
2721
2722 let event_tx = state.write_tx.clone();
2725 let unsub = host.subscribe(Arc::new(move |event: &AgentSessionEvent| {
2726 let _ = event_tx.send(WriteMessage::Line(to_jsonl(event)));
2727 }));
2728 *state.unsubscribe_events.lock().unwrap() = Some(unsub);
2729
2730 process_input_line(
2733 r#"{"type":"prompt","id":"q3","message":"test"}"#,
2734 &host,
2735 &state,
2736 )
2737 .await;
2738
2739 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2741 drain(&sink_clone, &mut write_rx).await;
2742
2743 let lines = sink_clone.stdout_lines();
2744 assert_eq!(
2745 lines.len(),
2746 2,
2747 "Expected exactly 2 frames: response and event"
2748 );
2749
2750 let r1: Value = serde_json::from_str(&lines[0]).unwrap();
2752 assert_eq!(r1["type"], "response");
2753 assert_eq!(r1["command"], "prompt");
2754 assert_eq!(r1["success"], true);
2755
2756 let r2: Value = serde_json::from_str(&lines[1]).unwrap();
2758 assert_eq!(r2["type"], "turn_start");
2759 }
2760 #[tokio::test]
2765 async fn shutdown_flag_triggers_exit() {
2766 let host = FakeRpcHost::new(FakeConfig::default());
2767 let sink = BufferSink::new();
2768 let (state, _, _) = make_state(sink);
2769 state.shutdown_requested.store(true, Ordering::SeqCst);
2771 let outcome = process_input_line(r#"{"type":"get_state","id":"x"}"#, &host, &state).await;
2772 assert_eq!(outcome, LineOutcome::Shutdown);
2773 }
2774
2775 #[tokio::test]
2776 async fn extension_shutdown_wakes_idle_loop() {
2777 let host = FakeRpcHost::new(FakeConfig::default());
2778 let observer = host.clone();
2779 let sink = Arc::new(BufferSink::new()) as Arc<dyn RpcSink>;
2780 let (_input_writer, input_reader) = tokio::io::duplex(64);
2781 let loop_task = tokio::spawn(run_rpc_loop(host, sink, input_reader));
2782
2783 tokio::time::timeout(std::time::Duration::from_secs(1), async {
2784 loop {
2785 let handler = observer
2786 .bindings
2787 .lock()
2788 .unwrap()
2789 .as_ref()
2790 .and_then(|bindings| bindings.shutdown_handler.clone());
2791 if let Some(handler) = handler {
2792 handler();
2793 break;
2794 }
2795 tokio::task::yield_now().await;
2796 }
2797 })
2798 .await
2799 .expect("extension bindings were not installed");
2800
2801 let code = tokio::time::timeout(std::time::Duration::from_secs(1), loop_task)
2802 .await
2803 .expect("idle RPC loop did not wake")
2804 .expect("RPC loop task panicked");
2805 assert_eq!(code, 0);
2806 }
2807
2808 #[tokio::test]
2813 async fn loop_eof_exit_zero() {
2814 let host = FakeRpcHost::new(FakeConfig::default());
2815 let sink = Arc::new(BufferSink::new()) as Arc<dyn RpcSink>;
2816 let input: &[u8] = b"";
2817 let code = run_rpc_loop(host, sink, input).await;
2818 assert_eq!(code, 0);
2819 }
2820
2821 #[tokio::test]
2822 async fn loop_stdin_read_error_is_protocol_visible_and_nonzero() {
2823 let host = FakeRpcHost::new(FakeConfig::default());
2824 let buffer = Arc::new(BufferSink::new());
2825 let sink = Arc::clone(&buffer) as Arc<dyn RpcSink>;
2826 let code = run_rpc_loop(host, sink, FailingInput).await;
2827 assert_eq!(code, 1);
2828 let lines = buffer.stdout_lines();
2829 assert_eq!(lines.len(), 1);
2830 let response: Value = serde_json::from_str(&lines[0]).unwrap();
2831 assert_eq!(response["type"], "response");
2832 assert_eq!(response["command"], "transport");
2833 assert_eq!(response["success"], false);
2834 assert!(
2835 response["error"]
2836 .as_str()
2837 .unwrap()
2838 .contains("stdin transport failed")
2839 );
2840 }
2841
2842 #[tokio::test]
2843 async fn loop_command_then_eof() {
2844 let host = FakeRpcHost::new(FakeConfig::default());
2845 let buf = BufferSink::new();
2846 let sink: Arc<dyn RpcSink> = Arc::new(buf.clone());
2847 let input: &[u8] = b"{\"type\":\"get_state\",\"id\":\"l1\"}\n";
2848 let code = run_rpc_loop(host, Arc::clone(&sink), input).await;
2849 assert_eq!(code, 0);
2850 assert_eq!(buf.stdout_lines().len(), 1);
2851 }
2852
2853 #[tokio::test]
2854 async fn command_dispatch_waits_for_writer_drain() {
2855 let host = FakeRpcHost::new(FakeConfig::default());
2856 let observer = host.clone();
2857 let gated = GatedSink::default();
2858 let sink = Arc::new(gated.clone()) as Arc<dyn RpcSink>;
2859 let input =
2860 &b"{\"type\":\"get_state\",\"id\":\"one\"}\n{\"type\":\"get_state\",\"id\":\"two\"}\n"
2861 [..];
2862 let loop_task = tokio::spawn(run_rpc_loop(host, sink, input));
2863
2864 gated.wait_for_write(1).await;
2865 assert_eq!(
2866 observer
2867 .calls
2868 .lock()
2869 .unwrap()
2870 .iter()
2871 .filter(|call| call.as_str() == "get_state")
2872 .count(),
2873 1,
2874 "second request dispatched before first response drained"
2875 );
2876 gated.release.notify_one();
2877
2878 gated.wait_for_write(2).await;
2879 gated.release.notify_one();
2880 let code = tokio::time::timeout(std::time::Duration::from_secs(1), loop_task)
2881 .await
2882 .expect("RPC loop did not finish")
2883 .expect("RPC loop task panicked");
2884 assert_eq!(code, 0);
2885 assert_eq!(
2886 observer
2887 .calls
2888 .lock()
2889 .unwrap()
2890 .iter()
2891 .filter(|call| call.as_str() == "get_state")
2892 .count(),
2893 2
2894 );
2895 }
2896
2897 #[tokio::test]
2898 async fn loop_disposes_on_exit() {
2899 let host = FakeRpcHost::new(FakeConfig::default());
2900 let sink = Arc::new(BufferSink::new()) as Arc<dyn RpcSink>;
2901 let input: &[u8] = b"";
2902 let _ = run_rpc_loop(host, sink, input).await;
2903 }
2906
2907 #[tokio::test]
2908 async fn loop_disposes_host() {
2909 let host = Arc::new(FakeRpcHost::new(FakeConfig::default()));
2910 let disposed = {
2911 let h = Arc::clone(&host);
2912 let sink = Arc::new(BufferSink::new()) as Arc<dyn RpcSink>;
2916 let (write_tx, _) = mpsc::unbounded_channel::<WriteMessage>();
2917 let state = ServerState::new(sink, write_tx, ExtensionUiProxy::new());
2918 state.rebind(&*h).await;
2919 state.cleanup(&*h, 0).await;
2920 h.disposed.load(Ordering::SeqCst)
2921 };
2922 assert!(disposed);
2923 }
2924
2925 #[tokio::test]
2930 async fn buffer_sink_fifo_order() {
2931 let sink = BufferSink::new();
2932 sink.clone().write_stdout("a\n".into()).await.unwrap();
2933 sink.clone().write_stdout("b\n".into()).await.unwrap();
2934 sink.clone().write_stdout("c\n".into()).await.unwrap();
2935 assert_eq!(sink.stdout_lines(), vec!["a", "b", "c"]);
2936 }
2937
2938 #[tokio::test]
2943 async fn command_without_id() {
2944 let (r, _) = dispatch(r#"{"type":"get_state"}"#, FakeConfig::default()).await;
2945 assert!(r.get("id").is_none() || r["id"].is_null());
2946 }
2947
2948 #[tokio::test]
2953 async fn rebind_binds_and_subscribes() {
2954 let host = FakeRpcHost::new(FakeConfig::default());
2955 let sink = Arc::new(BufferSink::new()) as Arc<dyn RpcSink>;
2956 let (write_tx, _) = mpsc::unbounded_channel::<WriteMessage>();
2957 let state = ServerState::new(sink, write_tx, ExtensionUiProxy::new());
2958 state.rebind(&host).await;
2959 let calls = host.calls.lock().unwrap();
2960 assert!(calls.contains(&"bind_extensions_rpc".to_owned()));
2961 assert!(calls.contains(&"subscribe".to_owned()));
2962 assert!(calls.contains(&"register_backpressure_hook".to_owned()));
2963 }
2964
2965 #[tokio::test]
2966 async fn rebind_extension_error_listener_preserves_structured_fields() {
2967 let host = FakeRpcHost::new(FakeConfig::default());
2968 let sink = Arc::new(BufferSink::new()) as Arc<dyn RpcSink>;
2969 let (write_tx, mut write_rx) = mpsc::unbounded_channel::<WriteMessage>();
2970 let state = ServerState::new(sink, write_tx, ExtensionUiProxy::new());
2971 state.rebind(&host).await;
2972
2973 let listener = host
2974 .bindings
2975 .lock()
2976 .unwrap()
2977 .as_ref()
2978 .and_then(|bindings| bindings.on_error.clone())
2979 .expect("RPC rebind must install extension error listener");
2980 listener("/workspace/ext.ts", "tool_call", "handler failed");
2981
2982 let WriteMessage::Line(line) = write_rx.recv().await.expect("extension error output")
2983 else {
2984 panic!("unexpected drain message");
2985 };
2986 let parsed: Value = serde_json::from_str(&line).unwrap();
2987 assert_eq!(parsed["extensionPath"], "/workspace/ext.ts");
2988 assert_eq!(parsed["event"], "tool_call");
2989 assert_eq!(parsed["error"], "handler failed");
2990 }
2991
2992 #[tokio::test]
2997 async fn extension_error_serializes() {
2998 let output = ExtensionErrorOutput::new("ext/path", "event_type", "boom");
2999 let line = to_jsonl(&output);
3000 let parsed: Value = serde_json::from_str(&line).unwrap();
3001 assert_eq!(parsed["type"], "extension_error");
3002 assert_eq!(parsed["extensionPath"], "ext/path");
3003 assert_eq!(parsed["event"], "event_type");
3004 assert_eq!(parsed["error"], "boom");
3005 }
3006 #[test]
3007 fn host_extension_error_wire_fields_are_recovered() {
3008 let output = map_extension_error_event(&ExtensionErrorEvent {
3009 code: "extension_error".to_owned(),
3010 message: "[/workspace/ext.ts] agent_start: handler failed".to_owned(),
3011 retryable: false,
3012 data: None,
3013 });
3014 let parsed: Value = serde_json::from_str(&to_jsonl(&output)).unwrap();
3015 assert_eq!(parsed["extensionPath"], "/workspace/ext.ts");
3016 assert_eq!(parsed["event"], "agent_start");
3017 assert_eq!(parsed["error"], "handler failed");
3018 }
3019
3020 #[test]
3021 fn structured_host_extension_error_data_takes_precedence() {
3022 let output = map_extension_error_event(&ExtensionErrorEvent {
3023 code: "extension_error".to_owned(),
3024 message: "legacy summary".to_owned(),
3025 retryable: false,
3026 data: Some(serde_json::json!({
3027 "extensionPath": "/workspace/structured.ts",
3028 "event": "tool_result",
3029 "error": "structured failure",
3030 })),
3031 });
3032 let parsed: Value = serde_json::from_str(&to_jsonl(&output)).unwrap();
3033 assert_eq!(parsed["extensionPath"], "/workspace/structured.ts");
3034 assert_eq!(parsed["event"], "tool_result");
3035 assert_eq!(parsed["error"], "structured failure");
3036 }
3037}