1use std::fs::OpenOptions;
8use std::io::Write;
9use std::path::PathBuf;
10use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
11use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
12use std::sync::{mpsc as std_mpsc, Arc, Mutex};
13use std::time::Duration;
14
15use async_trait::async_trait;
16use rpi_agent::{
17 AgentError, AgentTool, AgentToolResult, TextContentOrImage, ToolExecutionMode,
18 ToolResultPartial,
19};
20use rpi_ai::types::{Context, Schema, Tool};
21use rpi_ai::{CacheRetention, Model, Provider, SimpleStreamOptions};
22use rpi_tui::TUI;
23use tokio_util::sync::CancellationToken;
24
25use crate::node_transport::{NodeTransport, NodeTransportStartup, RuntimeHandler};
26
27const NODE_HOST: &str = include_str!("node_host.mjs");
28static NEXT_NODE_HOST_SCRIPT: AtomicU64 = AtomicU64::new(1);
29const NODE_HOST_STOPPED: &str = "Node extension host stopped";
30const NODE_PREPARATION_CANCELLED: &str = "Node extension preparation cancelled";
31
32#[derive(Debug, Clone, Default)]
33pub struct JsResources {
34 pub skill_paths: Vec<PathBuf>,
35 pub prompt_paths: Vec<PathBuf>,
36 pub theme_paths: Vec<PathBuf>,
37}
38
39#[derive(Debug, Clone)]
40struct JsToolDefinition {
41 name: String,
42 label: String,
43 execution_mode: ToolExecutionMode,
44 tool: Tool,
45}
46
47#[derive(Clone)]
48pub struct JsExtensionSession {
49 transport: LazyNodeTransport,
50 tools: Arc<Vec<JsToolDefinition>>,
51 pub resources: JsResources,
52 pub commands: Vec<String>,
53 capabilities: Arc<Mutex<Vec<String>>>,
54 custom_active: Arc<Mutex<Option<String>>>,
55 custom_visible: Arc<Mutex<bool>>,
56 custom_input_waiters: Arc<Mutex<std::collections::HashMap<u64, std_mpsc::Sender<bool>>>>,
57 next_custom_input_id: Arc<AtomicU64>,
58 api_version: u32,
59}
60
61impl JsExtensionSession {
62 pub fn load(paths: &[PathBuf], _verbose: bool) -> Result<Option<Self>, String> {
63 Self::load_with_context(paths, _verbose, serde_json::json!({}))
64 }
65
66 pub fn load_with_context(
67 paths: &[PathBuf],
68 _verbose: bool,
69 context: serde_json::Value,
70 ) -> Result<Option<Self>, String> {
71 let paths: Vec<PathBuf> = paths
72 .iter()
73 .filter(|path| path.exists())
74 .map(normalize_host_path)
75 .collect();
76 if paths.is_empty() {
77 return Ok(None);
78 }
79 let (_, init) = start_node_transport(&paths, &context, true, &[])?.wait()?;
80 if !init
81 .get("ok")
82 .and_then(serde_json::Value::as_bool)
83 .unwrap_or(false)
84 {
85 return Err(init
86 .get("error")
87 .and_then(serde_json::Value::as_str)
88 .unwrap_or("Node extension load failed")
89 .to_string());
90 }
91 let result = init.get("result").cloned().unwrap_or_default();
92 let mut definitions = Vec::new();
93 for item in result
94 .get("tools")
95 .and_then(serde_json::Value::as_array)
96 .into_iter()
97 .flatten()
98 {
99 let name = item
100 .get("name")
101 .and_then(serde_json::Value::as_str)
102 .unwrap_or_default()
103 .to_string();
104 if name.is_empty() {
105 continue;
106 }
107 let label = item
108 .get("label")
109 .and_then(serde_json::Value::as_str)
110 .unwrap_or(&name)
111 .to_string();
112 let description = item
113 .get("description")
114 .and_then(serde_json::Value::as_str)
115 .unwrap_or_default()
116 .to_string();
117 let parameters = item
118 .get("parameters")
119 .cloned()
120 .unwrap_or_else(|| serde_json::json!({"type":"object","properties":{}}));
121 let execution_mode = match item
122 .get("executionMode")
123 .and_then(serde_json::Value::as_str)
124 {
125 Some("sequential") => ToolExecutionMode::Sequential,
126 _ => ToolExecutionMode::Parallel,
127 };
128 definitions.push(JsToolDefinition {
129 name: name.clone(),
130 label,
131 execution_mode,
132 tool: Tool {
133 name,
134 description,
135 parameters: Schema::new(parameters),
136 constrained_sampling: None,
137 },
138 });
139 }
140 let resources = parse_resources(result.get("resources"));
141 let api_version = result
142 .get("apiVersion")
143 .and_then(serde_json::Value::as_u64)
144 .unwrap_or(crate::extension_api::EXTENSION_API_VERSION as u64)
145 as u32;
146 let commands = result
147 .get("commands")
148 .and_then(serde_json::Value::as_array)
149 .map(|values| {
150 values
151 .iter()
152 .filter_map(|value| value.as_str().map(String::from))
153 .collect()
154 })
155 .unwrap_or_default();
156 let capabilities = result
157 .get("capabilities")
158 .and_then(serde_json::Value::as_array)
159 .map(|values| {
160 values
161 .iter()
162 .filter_map(|value| value.as_str().map(String::from))
163 .collect()
164 })
165 .unwrap_or_default();
166 let mut persistent_context = context;
173 if let Some(active_tools) = result.get("activeTools") {
174 persistent_context["activeTools"] = active_tools.clone();
175 }
176 let transport = LazyNodeTransport::new(paths, persistent_context);
177 transport.record_active_tools(result.get("activeTools"));
178 Ok(Some(Self {
179 transport,
180 tools: Arc::new(definitions),
181 resources,
182 commands,
183 capabilities: Arc::new(Mutex::new(capabilities)),
184 custom_active: Arc::new(Mutex::new(None)),
185 custom_visible: Arc::new(Mutex::new(false)),
186 custom_input_waiters: Arc::new(Mutex::new(std::collections::HashMap::new())),
187 next_custom_input_id: Arc::new(AtomicU64::new(1)),
188 api_version,
189 }))
190 }
191
192 pub fn capabilities(&self) -> Vec<String> {
193 self.capabilities
194 .lock()
195 .map(|values| values.clone())
196 .unwrap_or_default()
197 }
198
199 pub fn tools(&self) -> impl Iterator<Item = JsToolAdapter> + '_ {
200 self.tools.iter().cloned().map(|definition| JsToolAdapter {
201 session: self.clone(),
202 definition,
203 })
204 }
205
206 fn invoke(
207 &self,
208 method: &str,
209 payload: serde_json::Value,
210 ) -> Result<serde_json::Value, String> {
211 self.transport.request(method, payload)
212 }
213
214 pub fn invoke_command(&self, command: &str, args: &str) -> Result<serde_json::Value, String> {
215 self.invoke_command_with_context(command, args, serde_json::json!({}))
216 }
217
218 pub fn invoke_command_with_context(
219 &self,
220 command: &str,
221 args: &str,
222 context: serde_json::Value,
223 ) -> Result<serde_json::Value, String> {
224 let result = self.invoke(
225 "invoke_command",
226 serde_json::json!({"command": command, "args": args, "context": context}),
227 )?;
228 self.transport
229 .record_active_tools(result.get("activeTools"));
230 Ok(result)
231 }
232
233 pub fn set_runtime_context(&self, context: serde_json::Value) -> Result<(), String> {
234 self.transport.set_runtime_context(context)
235 }
236
237 pub fn set_runtime_handler(&self, handler: RuntimeHandler) -> Result<(), String> {
238 self.transport.replace_runtime_handlers(handler)
239 }
240
241 pub fn add_runtime_handler(&self, handler: RuntimeHandler) -> Result<(), String> {
242 self.transport.add_runtime_handler(handler)
243 }
244
245 pub fn shutdown(&self) {
249 self.clear_custom_state();
250 self.transport.shutdown();
251 }
252
253 fn clear_custom_state(&self) {
254 if let Ok(mut active) = self.custom_active.lock() {
255 *active = None;
256 }
257 if let Ok(mut visible) = self.custom_visible.lock() {
258 *visible = false;
259 }
260 let waiters = self
261 .custom_input_waiters
262 .lock()
263 .map(|mut waiters| {
264 waiters
265 .drain()
266 .map(|(_, sender)| sender)
267 .collect::<Vec<_>>()
268 })
269 .unwrap_or_default();
270 for sender in waiters {
271 let _ = sender.send(false);
272 }
273 }
274
275 pub fn custom_active(&self) -> bool {
276 self.custom_active
277 .lock()
278 .map(|value| value.is_some())
279 .unwrap_or(false)
280 }
281
282 pub fn active_tools(&self) -> Option<Vec<String>> {
286 self.transport.active_tools()
287 }
288
289 pub fn tool_names(&self) -> Vec<String> {
290 self.tools.iter().map(|tool| tool.name.clone()).collect()
291 }
292
293 pub fn custom_accepts_input(&self) -> bool {
297 self.custom_active()
298 && self
299 .custom_visible
300 .lock()
301 .map(|value| *value)
302 .unwrap_or(false)
303 }
304
305 pub fn send_custom_input(&self, data: &str) -> Result<(), String> {
306 let id = self
307 .custom_active
308 .lock()
309 .map_err(|_| "Node custom state lock poisoned")?
310 .clone()
311 .ok_or("no active Node custom UI")?;
312 self.transport.send_event(serde_json::json!({
316 "type": "host_event",
317 "event": "custom_input",
318 "customId": id,
319 "data": data,
320 }))
321 }
322
323 pub fn send_custom_input_with_consumed(&self, data: &str) -> Result<bool, String> {
328 let id = self
329 .custom_active
330 .lock()
331 .map_err(|_| "Node custom state lock poisoned")?
332 .clone()
333 .ok_or("no active Node custom UI")?;
334 let hidden = !self.custom_accepts_input();
335 let input_id = self.next_custom_input_id.fetch_add(1, Ordering::Relaxed);
336 let (sender, receiver) = std_mpsc::channel();
337 self.custom_input_waiters
338 .lock()
339 .map_err(|_| "Node custom input lock poisoned")?
340 .insert(input_id, sender);
341 let event = serde_json::json!({
342 "type": "host_event",
343 "event": "custom_input",
344 "customId": id,
345 "data": data,
346 "inputId": input_id,
347 "hidden": hidden,
348 });
349 if let Err(error) = self.transport.send_event(event) {
350 if let Ok(mut waiters) = self.custom_input_waiters.lock() {
351 waiters.remove(&input_id);
352 }
353 return Err(error);
354 }
355 match receiver.recv_timeout(Duration::from_millis(250)) {
356 Ok(consumed) => Ok(consumed),
357 Err(std_mpsc::RecvTimeoutError::Timeout) => {
358 if let Ok(mut waiters) = self.custom_input_waiters.lock() {
359 waiters.remove(&input_id);
360 }
361 Ok(false)
364 }
365 Err(std_mpsc::RecvTimeoutError::Disconnected) => {
366 if let Ok(mut waiters) = self.custom_input_waiters.lock() {
367 waiters.remove(&input_id);
368 }
369 Ok(false)
370 }
371 }
372 }
373
374 pub fn send_custom_resize(&self, width: usize, height: usize) -> Result<(), String> {
375 let id = self
376 .custom_active
377 .lock()
378 .map_err(|_| "Node custom state lock poisoned")?
379 .clone()
380 .ok_or("no active Node custom UI")?;
381 let event = serde_json::json!({
382 "type": "host_event",
383 "event": "custom_resize",
384 "customId": id,
385 "width": width,
386 "height": height,
387 });
388 self.transport.send_event(event)
389 }
390
391 pub fn install_ui_runtime(&self, tui: Arc<rpi_tui::TuiAltScreen>) -> Result<(), String> {
392 let active = self.custom_active.clone();
393 let visible = self.custom_visible.clone();
394 let input_waiters = self.custom_input_waiters.clone();
395 self.add_runtime_handler(Arc::new(move |action, args| match action {
396 "ui.custom.open" => {
397 let id = args
398 .get("customId")
399 .and_then(serde_json::Value::as_str)
400 .ok_or("ui.custom.open missing customId")?
401 .to_string();
402 *visible
403 .lock()
404 .map_err(|_| "Node custom visibility lock poisoned")? = true;
405 *active
406 .lock()
407 .map_err(|_| "Node custom state lock poisoned")? = Some(id.clone());
408 tui.set_render_suspended(true);
409 tui.terminal().clear_screen();
410 let info = tui.terminal().info();
411 Ok(serde_json::json!({
412 "columns": info.columns,
413 "rows": info.rows,
414 }))
415 }
416 "ui.custom.write" => {
417 let data = args
418 .get("data")
419 .and_then(serde_json::Value::as_str)
420 .ok_or("ui.custom.write missing data")?;
421 tui.terminal().write(data);
422 Ok(serde_json::json!(true))
423 }
424 "ui.custom.invalidate" => {
425 tui.request_render(false);
426 Ok(serde_json::json!(true))
427 }
428 "ui.custom.input" => {
429 let input_id = args
430 .get("inputId")
431 .and_then(serde_json::Value::as_u64)
432 .ok_or("ui.custom.input missing inputId")?;
433 let consumed = args
434 .get("consumed")
435 .and_then(serde_json::Value::as_bool)
436 .unwrap_or(false);
437 if let Ok(mut waiters) = input_waiters.lock() {
438 if let Some(sender) = waiters.remove(&input_id) {
439 let _ = sender.send(consumed);
440 }
441 }
442 Ok(serde_json::json!(consumed))
443 }
444 "ui.custom.handle" => {
445 let operation = args
446 .get("operation")
447 .and_then(serde_json::Value::as_str)
448 .ok_or("ui.custom.handle missing operation")?;
449 let id = args
450 .get("customId")
451 .and_then(serde_json::Value::as_str)
452 .ok_or("ui.custom.handle missing customId")?;
453 let is_active = active
454 .lock()
455 .map_err(|_| "Node custom state lock poisoned")?
456 .as_deref()
457 == Some(id);
458 if !is_active {
459 return Ok(serde_json::json!(false));
460 }
461 match operation {
462 "setHidden" | "hide" => {
463 let hidden = args
464 .get("hidden")
465 .and_then(serde_json::Value::as_bool)
466 .unwrap_or(operation == "hide");
467 tui.terminal().clear_screen();
468 tui.set_render_suspended(!hidden);
472 *visible
473 .lock()
474 .map_err(|_| "Node custom visibility lock poisoned")? = !hidden;
475 if !hidden {
476 tui.terminal().clear_screen();
477 }
478 }
479 "focus" | "unfocus" => {}
482 _ => return Err(format!("unsupported custom handle operation: {operation}")),
483 }
484 Ok(serde_json::json!(true))
485 }
486 "ui.custom.close" => {
487 let restore_custom_id = args
488 .get("restoreCustomId")
489 .and_then(serde_json::Value::as_str)
490 .map(String::from);
491 let restore_hidden = args
492 .get("restoreHidden")
493 .and_then(serde_json::Value::as_bool)
494 .unwrap_or(false);
495 *visible
496 .lock()
497 .map_err(|_| "Node custom visibility lock poisoned")? =
498 restore_custom_id.is_some() && !restore_hidden;
499 *active
500 .lock()
501 .map_err(|_| "Node custom state lock poisoned")? = restore_custom_id.clone();
502 tui.terminal().clear_screen();
506 if restore_custom_id.is_none() || restore_hidden {
511 tui.set_render_suspended(false);
512 }
513 Ok(serde_json::json!(true))
514 }
515 "ui.custom.resize" => Ok(serde_json::json!(true)),
516 _ => Err(format!("unsupported capability: {action}")),
517 }))?;
518 self.enable_capability("ui.custom")?;
519 self.set_runtime_context(serde_json::json!({
520 "mode": "tui",
521 "hasUI": true,
522 "capabilities":["ui.custom"]
523 }))
524 }
525
526 pub fn ensure_runtime(&self) -> Result<(), String> {
532 self.transport.ensure().map(|_| ())
533 }
534
535 pub fn prepare_for_prompt(&self) -> Result<(), String> {
540 self.prepare_for_prompt_with_cancellation(&CancellationToken::new())
541 }
542
543 pub(crate) fn prepare_for_prompt_with_cancellation(
544 &self,
545 cancellation: &CancellationToken,
546 ) -> Result<(), String> {
547 if cancellation.is_cancelled() {
548 return Err(NODE_PREPARATION_CANCELLED.into());
549 }
550 let already_live = self.transport.live_transport()?.is_some();
551 self.transport
552 .ensure_with_cancellation(Some(cancellation))?;
553 if cancellation.is_cancelled() {
554 return Err(NODE_PREPARATION_CANCELLED.into());
555 }
556 if already_live {
557 self.transport.set_runtime_context(serde_json::json!({}))?;
558 }
559 if cancellation.is_cancelled() {
560 return Err(NODE_PREPARATION_CANCELLED.into());
561 }
562 Ok(())
563 }
564
565 pub(crate) fn cancel_prompt_preparation(&self) {
569 self.clear_custom_state();
570 self.transport.stop_current();
571 }
572
573 pub fn install_ui_dialog_runtime(&self, handler: RuntimeHandler) -> Result<(), String> {
583 self.add_runtime_handler(handler)?;
584 for capability in ["ui.select", "ui.confirm", "ui.input", "ui.editor_dialog"] {
585 self.enable_capability(capability)?;
586 }
587 self.set_runtime_context(serde_json::json!({
588 "mode": "tui",
589 "hasUI": true,
590 "capabilities": ["ui.select", "ui.confirm", "ui.input", "ui.editor_dialog"],
591 }))
592 }
593
594 pub fn enable_capability(&self, capability: &str) -> Result<(), String> {
595 let mut capabilities = self
596 .capabilities
597 .lock()
598 .map_err(|_| "Node capability lock poisoned")?;
599 if !capabilities.iter().any(|value| value == capability) {
600 capabilities.push(capability.to_string());
601 }
602 Ok(())
603 }
604
605 pub fn enable_provider_runtime(
609 &self,
610 provider: Arc<dyn Provider>,
611 runtime: tokio::runtime::Handle,
612 ) -> Result<(), String> {
613 let provider_id = provider.id().to_string();
614 self.set_runtime_handler(Arc::new(move |action, args| {
615 if !matches!(action, "provider.complete" | "provider.stream") {
616 return Err(format!("unsupported capability: {action}"));
617 }
618 let model: Model = serde_json::from_value(
619 args.get("model")
620 .cloned()
621 .ok_or("provider.complete missing model")?,
622 )
623 .map_err(|error| format!("invalid provider model: {error}"))?;
624 if model.provider != provider_id {
625 return Err(format!(
626 "no provider registered for model provider '{}'",
627 model.provider
628 ));
629 }
630 let context = normalize_provider_context(
631 args.get("context")
632 .cloned()
633 .ok_or("provider.complete missing context")?,
634 )
635 .map_err(|error| format!("invalid provider context: {error}"))?;
636 let options = parse_simple_stream_options(args.get("options"))?;
637 let stream = runtime.block_on(provider.stream_simple(&model, &context, &options));
638 if action == "provider.complete" {
639 let message = runtime
640 .block_on(stream.result())
641 .map_err(|error| format!("provider stream failed: {error}"))?;
642 return serde_json::to_value(message)
643 .map_err(|error| format!("provider result encode failed: {error}"));
644 }
645
646 let (mut events_rx, result_rx) = stream.split();
651 let (events, message) = runtime.block_on(async move {
652 let mut events = Vec::new();
653 while let Some(event) = events_rx.recv().await {
654 events.push(
655 serde_json::to_value(event)
656 .map_err(|error| format!("provider event encode failed: {error}"))?,
657 );
658 }
659 let message = result_rx
660 .await
661 .map_err(|error| format!("provider stream failed: {error}"))?;
662 Ok::<_, String>((events, message))
663 })?;
664 Ok(serde_json::json!({
665 "events": events,
666 "result": serde_json::to_value(message)
667 .map_err(|error| format!("provider result encode failed: {error}"))?,
668 }))
669 }))?;
670 self.enable_capability("provider_calls")?;
671 self.set_runtime_context(serde_json::json!({
672 "capabilities": ["provider_calls"],
673 }))
674 }
675}
676
677struct LazyNodeTransport {
680 owner: Arc<AtomicUsize>,
686 paths: Arc<Vec<PathBuf>>,
687 context: Arc<serde_json::Value>,
688 transport: Arc<Mutex<Option<NodeTransport>>>,
689 handlers: Arc<Mutex<Vec<RuntimeHandler>>>,
690 pending_context: Arc<Mutex<Option<serde_json::Value>>>,
691 context_revision: Arc<AtomicU64>,
692 active_tools: Arc<Mutex<Option<Vec<String>>>>,
693 shutdown: Arc<AtomicBool>,
694 start_lock: Arc<Mutex<()>>,
697 starting: Arc<Mutex<Option<NodeTransport>>>,
700 context_lock: Arc<Mutex<()>>,
703}
704
705impl Clone for LazyNodeTransport {
706 fn clone(&self) -> Self {
707 self.owner.fetch_add(1, Ordering::Relaxed);
708 Self {
709 owner: self.owner.clone(),
710 paths: self.paths.clone(),
711 context: self.context.clone(),
712 transport: self.transport.clone(),
713 handlers: self.handlers.clone(),
714 pending_context: self.pending_context.clone(),
715 context_revision: self.context_revision.clone(),
716 active_tools: self.active_tools.clone(),
717 shutdown: self.shutdown.clone(),
718 start_lock: self.start_lock.clone(),
719 starting: self.starting.clone(),
720 context_lock: self.context_lock.clone(),
721 }
722 }
723}
724
725impl Drop for LazyNodeTransport {
726 fn drop(&mut self) {
727 if self.owner.fetch_sub(1, Ordering::AcqRel) == 1 {
731 self.shutdown();
732 }
733 }
734}
735
736impl LazyNodeTransport {
737 fn new(paths: Vec<PathBuf>, context: serde_json::Value) -> Self {
738 Self {
739 owner: Arc::new(AtomicUsize::new(1)),
740 paths: Arc::new(paths),
741 context: Arc::new(context),
742 transport: Arc::new(Mutex::new(None)),
743 handlers: Arc::new(Mutex::new(Vec::new())),
744 pending_context: Arc::new(Mutex::new(None)),
745 context_revision: Arc::new(AtomicU64::new(0)),
746 active_tools: Arc::new(Mutex::new(None)),
747 shutdown: Arc::new(AtomicBool::new(false)),
748 start_lock: Arc::new(Mutex::new(())),
749 starting: Arc::new(Mutex::new(None)),
750 context_lock: Arc::new(Mutex::new(())),
751 }
752 }
753
754 fn live_transport(&self) -> Result<Option<NodeTransport>, String> {
755 let mut slot = self
756 .transport
757 .lock()
758 .map_err(|_| "Node transport lock poisoned")?;
759 match slot.as_ref() {
760 Some(transport) if !transport.is_shutdown() => Ok(Some(transport.clone())),
761 Some(_) => {
762 slot.take();
765 Ok(None)
766 }
767 None => Ok(None),
768 }
769 }
770
771 fn shutdown(&self) {
772 self.shutdown.store(true, Ordering::Release);
773 self.stop_current();
774 }
775
776 fn stop_current(&self) {
780 let active = self.transport.lock().ok().and_then(|mut slot| slot.take());
783 let starting = self.starting.lock().ok().and_then(|mut slot| slot.take());
784 if let Some(active) = active.as_ref() {
785 active.shutdown();
786 }
787 if let Some(starting) = starting {
788 if active
789 .as_ref()
790 .map_or(true, |active| !active.same_instance(&starting))
791 {
792 starting.shutdown();
793 }
794 }
795 }
796
797 fn active_tools(&self) -> Option<Vec<String>> {
798 self.active_tools
799 .lock()
800 .ok()
801 .and_then(|value| value.clone())
802 }
803
804 fn record_active_tools(&self, value: Option<&serde_json::Value>) {
805 let Some(values) = value.and_then(serde_json::Value::as_array) else {
806 return;
807 };
808 let names = values
809 .iter()
810 .filter_map(serde_json::Value::as_str)
811 .map(ToOwned::to_owned)
812 .collect();
813 if let Ok(mut active) = self.active_tools.lock() {
814 *active = Some(names);
815 }
816 }
817
818 fn restore_pending_context(&self, restored: Option<serde_json::Value>) {
825 let Some(mut restored) = restored else {
826 return;
827 };
828 let Ok(_context_guard) = self.context_lock.lock() else {
829 return;
830 };
831 let Ok(mut pending) = self.pending_context.lock() else {
832 return;
833 };
834 if let Some(current) = pending.take() {
835 merge_runtime_context(&mut restored, ¤t);
836 }
837 *pending = Some(restored);
838 }
839
840 fn ensure(&self) -> Result<NodeTransport, String> {
841 self.ensure_with_cancellation(None)
842 }
843
844 fn ensure_with_cancellation(
845 &self,
846 cancellation: Option<&CancellationToken>,
847 ) -> Result<NodeTransport, String> {
848 let cancelled = || cancellation.is_some_and(CancellationToken::is_cancelled);
849 if cancelled() {
850 return Err(NODE_PREPARATION_CANCELLED.into());
851 }
852 if self.shutdown.load(Ordering::Acquire) {
853 return Err(NODE_HOST_STOPPED.into());
854 }
855 if let Some(transport) = self.live_transport()? {
856 return Ok(transport);
857 }
858
859 let _start_guard = self
863 .start_lock
864 .lock()
865 .map_err(|_| "Node startup lock poisoned")?;
866 if cancelled() {
867 return Err(NODE_PREPARATION_CANCELLED.into());
868 }
869 if self.shutdown.load(Ordering::Acquire) {
870 return Err(NODE_HOST_STOPPED.into());
871 }
872 if let Some(transport) = self.live_transport()? {
873 return Ok(transport);
874 }
875 let initial_handlers = self
876 .handlers
877 .lock()
878 .map_err(|_| "Node runtime handler lock poisoned")?
879 .clone();
880 let (initial_context, initial_pending, initial_revision) = {
886 let _context_guard = self
887 .context_lock
888 .lock()
889 .map_err(|_| "Node runtime context lock poisoned")?;
890 let mut context = (*self.context).clone();
891 let mut pending = self
892 .pending_context
893 .lock()
894 .map_err(|_| "Node runtime context lock poisoned")?;
895 let initial_pending = pending.take();
896 let initial_revision = self.context_revision.load(Ordering::Acquire);
897 if let Some(next) = initial_pending.as_ref() {
898 merge_runtime_context(&mut context, next);
899 }
900 (context, initial_pending, initial_revision)
901 };
902
903 let startup =
908 match start_node_transport(&self.paths, &initial_context, false, &initial_handlers) {
909 Ok(startup) => startup,
910 Err(error) => {
911 self.restore_pending_context(initial_pending.clone());
912 return Err(error);
913 }
914 };
915 let transport = startup.transport();
916
917 if cancelled() {
920 transport.shutdown();
921 self.restore_pending_context(initial_pending.clone());
922 return Err(NODE_PREPARATION_CANCELLED.into());
923 }
924
925 {
926 let mut starting = self
927 .starting
928 .lock()
929 .map_err(|_| "Node startup state lock poisoned")?;
930 if self.shutdown.load(Ordering::Acquire) || cancelled() {
931 drop(starting);
932 transport.shutdown();
933 self.restore_pending_context(initial_pending.clone());
934 return Err(if cancelled() {
935 NODE_PREPARATION_CANCELLED.into()
936 } else {
937 NODE_HOST_STOPPED.into()
938 });
939 }
940 *starting = Some(transport.clone());
941 if cancelled() {
945 let stopped = starting.take();
946 drop(starting);
947 if let Some(stopped) = stopped {
948 stopped.shutdown();
949 }
950 self.restore_pending_context(initial_pending.clone());
951 return Err(NODE_PREPARATION_CANCELLED.into());
952 }
953 }
954
955 let init = match startup.wait() {
956 Ok((_, init)) => init,
957 Err(error) => {
958 if let Ok(mut starting) = self.starting.lock() {
959 starting.take();
960 }
961 self.restore_pending_context(initial_pending.clone());
962 return Err(error);
963 }
964 };
965
966 let startup_result = (|| -> Result<NodeTransport, String> {
967 if cancelled() {
968 return Err(NODE_PREPARATION_CANCELLED.into());
969 }
970 if !init
971 .get("ok")
972 .and_then(serde_json::Value::as_bool)
973 .unwrap_or(false)
974 {
975 return Err(init
976 .get("error")
977 .and_then(serde_json::Value::as_str)
978 .unwrap_or("Node extension load failed")
979 .to_string());
980 }
981 self.record_active_tools(
982 init.get("result")
983 .and_then(|value| value.get("activeTools")),
984 );
985 let handlers = self
986 .handlers
987 .lock()
988 .map_err(|_| "Node runtime handler lock poisoned")?
989 .clone();
990 if let Some(first) = handlers.first() {
991 transport.replace_runtime_handlers(first.clone())?;
992 for handler in handlers.iter().skip(1).cloned() {
993 transport.add_runtime_handler(handler)?;
994 }
995 }
996 let mut startup_context = initial_pending
1000 .clone()
1001 .unwrap_or_else(|| serde_json::json!({}));
1002 let mut startup_pending = Some((
1015 initial_pending
1016 .clone()
1017 .unwrap_or_else(|| serde_json::json!({})),
1018 initial_revision,
1019 ));
1020 loop {
1024 if cancelled() {
1025 return Err(NODE_PREPARATION_CANCELLED.into());
1026 }
1027 let next = if let Some(next) = startup_pending.take() {
1028 Some(next)
1029 } else {
1030 let _context_guard = self
1031 .context_lock
1032 .lock()
1033 .map_err(|_| "Node runtime context lock poisoned")?;
1034 let mut pending = self
1035 .pending_context
1036 .lock()
1037 .map_err(|_| "Node runtime context lock poisoned")?;
1038 if let Some(context) = pending.take() {
1039 let revision = self.context_revision.load(Ordering::Acquire);
1040 Some((context, revision))
1041 } else {
1042 *pending = Some(startup_context.clone());
1047 let mut slot = self
1048 .transport
1049 .lock()
1050 .map_err(|_| "Node transport lock poisoned")?;
1051 if self.shutdown.load(Ordering::Acquire) || cancelled() {
1052 return Err(if cancelled() {
1053 NODE_PREPARATION_CANCELLED.into()
1054 } else {
1055 NODE_HOST_STOPPED.into()
1056 });
1057 }
1058 if let Some(existing) = slot.as_ref() {
1059 return Ok(existing.clone());
1060 }
1061 *slot = Some(transport.clone());
1062 if self.shutdown.load(Ordering::Acquire) || cancelled() {
1067 let stopped = slot.take();
1068 drop(slot);
1069 if let Some(stopped) = stopped {
1070 stopped.shutdown();
1071 }
1072 return Err(if cancelled() {
1073 NODE_PREPARATION_CANCELLED.into()
1074 } else {
1075 NODE_HOST_STOPPED.into()
1076 });
1077 }
1078 return Ok(transport.clone());
1079 }
1080 };
1081 let Some((context, revision)) = next else {
1082 continue;
1083 };
1084 if cancelled() {
1085 return Err(NODE_PREPARATION_CANCELLED.into());
1086 }
1087 merge_runtime_context(&mut startup_context, &context);
1088 let result = match transport.request(
1089 "set_runtime_context",
1090 serde_json::json!({"context": context, "revision": revision}),
1091 ) {
1092 Ok(result) => result,
1093 Err(error) => {
1094 self.restore_pending_context(Some(startup_context));
1099 return Err(error);
1100 }
1101 };
1102 self.record_active_tools(result.get("activeTools"));
1103 }
1104 })();
1105
1106 if let Ok(mut starting) = self.starting.lock() {
1109 starting.take();
1110 }
1111 match startup_result {
1112 Ok(transport) => Ok(transport),
1113 Err(error) => {
1114 transport.shutdown();
1115 self.restore_pending_context(initial_pending);
1116 Err(error)
1117 }
1118 }
1119 }
1120
1121 fn request(
1122 &self,
1123 method: &str,
1124 payload: serde_json::Value,
1125 ) -> Result<serde_json::Value, String> {
1126 self.ensure()?.request(method, payload)
1127 }
1128
1129 fn begin_request(
1130 &self,
1131 method: &str,
1132 payload: serde_json::Value,
1133 ) -> Result<crate::node_transport::PendingRequest, String> {
1134 self.ensure()?.begin_request(method, payload)
1135 }
1136
1137 fn send_event(&self, event: serde_json::Value) -> Result<(), String> {
1138 self.ensure()?.send_event(event)
1139 }
1140
1141 fn cancel(&self, id: u64) -> Result<(), String> {
1142 self.ensure()?.cancel(id)
1143 }
1144
1145 fn register_tool_update_handler(
1146 &self,
1147 tool_call_id: impl Into<String>,
1148 handler: crate::node_transport::ToolUpdateHandler,
1149 ) -> Result<(), String> {
1150 self.ensure()?
1151 .register_tool_update_handler(tool_call_id, handler)
1152 }
1153
1154 fn unregister_tool_update_handler(&self, tool_call_id: &str) {
1155 if let Ok(slot) = self.transport.lock() {
1156 if let Some(transport) = slot.as_ref() {
1157 transport.unregister_tool_update_handler(tool_call_id);
1158 }
1159 }
1160 }
1161
1162 fn handler_update_transports(&self) -> Result<Vec<NodeTransport>, String> {
1167 let active = self.live_transport()?;
1168 let starting = self
1169 .starting
1170 .lock()
1171 .map_err(|_| "Node startup state lock poisoned")?
1172 .as_ref()
1173 .filter(|transport| !transport.is_shutdown())
1174 .cloned();
1175 let mut transports = Vec::with_capacity(2);
1176 if let Some(active) = active {
1177 transports.push(active);
1178 }
1179 if let Some(starting) = starting {
1180 if !transports
1181 .iter()
1182 .any(|transport| transport.same_instance(&starting))
1183 {
1184 transports.push(starting);
1185 }
1186 }
1187 Ok(transports)
1188 }
1189
1190 fn replace_runtime_handlers(&self, handler: RuntimeHandler) -> Result<(), String> {
1191 *self
1192 .handlers
1193 .lock()
1194 .map_err(|_| "Node runtime handler lock poisoned")? = vec![handler.clone()];
1195 for transport in self.handler_update_transports()? {
1196 transport.replace_runtime_handlers(handler.clone())?;
1197 }
1198 Ok(())
1199 }
1200
1201 fn add_runtime_handler(&self, handler: RuntimeHandler) -> Result<(), String> {
1202 self.handlers
1203 .lock()
1204 .map_err(|_| "Node runtime handler lock poisoned")?
1205 .push(handler.clone());
1206 for transport in self.handler_update_transports()? {
1207 transport.add_runtime_handler(handler.clone())?;
1208 }
1209 Ok(())
1210 }
1211
1212 fn set_runtime_context(&self, context: serde_json::Value) -> Result<(), String> {
1213 if self.shutdown.load(Ordering::Acquire) {
1214 return Err(NODE_HOST_STOPPED.into());
1215 }
1216 let (merged, revision, transport) = {
1220 let _context_guard = self
1221 .context_lock
1222 .lock()
1223 .map_err(|_| "Node runtime context lock poisoned")?;
1224 let mut pending = self
1225 .pending_context
1226 .lock()
1227 .map_err(|_| "Node runtime context lock poisoned")?;
1228 let mut merged = pending.take().unwrap_or_else(|| serde_json::json!({}));
1229 merge_runtime_context(&mut merged, &context);
1230 *pending = Some(merged.clone());
1231 let revision = self.context_revision.fetch_add(1, Ordering::AcqRel) + 1;
1232 let transport = self.live_transport()?;
1237 (merged, revision, transport)
1238 };
1239 if let Some(transport) = transport {
1240 let result = transport.request(
1241 "set_runtime_context",
1242 serde_json::json!({"context": merged, "revision": revision}),
1243 )?;
1244 self.record_active_tools(result.get("activeTools"));
1245 }
1246 Ok(())
1247 }
1248}
1249
1250fn merge_runtime_context(base: &mut serde_json::Value, next: &serde_json::Value) {
1256 let (Some(base_object), Some(next_object)) = (base.as_object_mut(), next.as_object()) else {
1257 *base = next.clone();
1258 return;
1259 };
1260 for (key, value) in next_object {
1261 if key == "capabilities" {
1262 let Some(next_values) = value.as_array() else {
1263 base_object.insert(key.clone(), value.clone());
1264 continue;
1265 };
1266 let entry = base_object
1267 .entry(key.clone())
1268 .or_insert_with(|| serde_json::Value::Array(Vec::new()));
1269 let Some(base_values) = entry.as_array_mut() else {
1270 *entry = serde_json::Value::Array(Vec::new());
1271 let Some(base_values) = entry.as_array_mut() else {
1272 continue;
1273 };
1274 for item in next_values {
1275 if !base_values.contains(item) {
1276 base_values.push(item.clone());
1277 }
1278 }
1279 continue;
1280 };
1281 for item in next_values {
1282 if !base_values.contains(item) {
1283 base_values.push(item.clone());
1284 }
1285 }
1286 } else {
1287 base_object.insert(key.clone(), value.clone());
1288 }
1289 }
1290}
1291
1292fn normalize_provider_context(mut value: serde_json::Value) -> Result<Context, String> {
1297 let Some(messages) = value
1298 .get_mut("messages")
1299 .and_then(serde_json::Value::as_array_mut)
1300 else {
1301 return serde_json::from_value(value).map_err(|error| error.to_string());
1302 };
1303 for message in messages {
1304 let Some(object) = message.as_object_mut() else {
1305 continue;
1306 };
1307 if object.contains_key("role") {
1308 continue;
1309 }
1310 let inferred = if object.contains_key("toolCallId")
1311 || object.contains_key("toolName")
1312 || object.contains_key("isError")
1313 {
1314 Some("toolResult")
1315 } else if object.contains_key("stopReason")
1316 && object.contains_key("content")
1317 && (object.contains_key("provider") || object.contains_key("model"))
1318 {
1319 Some("assistant")
1320 } else {
1321 None
1322 };
1323 if let Some(role) = inferred {
1324 object.insert(
1325 "role".to_string(),
1326 serde_json::Value::String(role.to_string()),
1327 );
1328 }
1329 }
1330 serde_json::from_value(value).map_err(|error| error.to_string())
1331}
1332
1333fn parse_simple_stream_options(
1334 value: Option<&serde_json::Value>,
1335) -> Result<SimpleStreamOptions, String> {
1336 let value = value.cloned().unwrap_or_default();
1337 let mut options = SimpleStreamOptions::default();
1338 options.api_key = value
1339 .get("apiKey")
1340 .and_then(|v| v.as_str())
1341 .map(String::from);
1342 options.headers = value
1343 .get("headers")
1344 .cloned()
1345 .map(serde_json::from_value)
1346 .transpose()
1347 .map_err(|e| format!("invalid headers: {e}"))?;
1348 options.metadata = value
1349 .get("metadata")
1350 .cloned()
1351 .map(serde_json::from_value)
1352 .transpose()
1353 .map_err(|e| format!("invalid metadata: {e}"))?;
1354 options.timeout = value
1355 .get("timeout")
1356 .and_then(|v| v.as_u64())
1357 .map(Duration::from_millis);
1358 options.max_retries = value
1359 .get("maxRetries")
1360 .and_then(|v| v.as_u64())
1361 .map(|value| value as u32);
1362 options.max_retry_delay = value
1363 .get("maxRetryDelay")
1364 .and_then(|v| v.as_u64())
1365 .map(Duration::from_millis);
1366 options.session_id = value
1367 .get("sessionId")
1368 .and_then(|v| v.as_str())
1369 .map(String::from);
1370 options.max_tokens = value.get("maxTokens").and_then(|v| v.as_u64());
1371 options.temperature = value.get("temperature").and_then(|v| v.as_f64());
1372 options.reasoning = value
1373 .get("reasoning")
1374 .cloned()
1375 .map(serde_json::from_value)
1376 .transpose()
1377 .map_err(|e| format!("invalid reasoning: {e}"))?;
1378 options.thinking_budgets = value
1379 .get("thinkingBudgets")
1380 .cloned()
1381 .map(serde_json::from_value)
1382 .transpose()
1383 .map_err(|e| format!("invalid thinkingBudgets: {e}"))?;
1384 options.cache_retention = match value.get("cacheRetention").and_then(|v| v.as_str()) {
1385 Some("long") => CacheRetention::Long,
1386 Some("none") => CacheRetention::None,
1387 _ => CacheRetention::Short,
1388 };
1389 Ok(options)
1390}
1391
1392impl crate::extension_api::ExtensionBackend for JsExtensionSession {
1393 fn backend_info(&self) -> crate::extension_api::ExtensionBackendInfo {
1394 use crate::extension_api::{ExtensionBackendInfo, ExtensionCapability};
1395
1396 let mut info = ExtensionBackendInfo::new("node");
1397 info.api_version = self.api_version;
1398 for capability in self
1399 .capabilities()
1400 .iter()
1401 .filter_map(|name| match name.as_str() {
1402 "tools" => Some(ExtensionCapability::Tools),
1403 "commands" => Some(ExtensionCapability::Commands),
1404 "resources" => Some(ExtensionCapability::Resources),
1405 "ui.notify" => Some(ExtensionCapability::UiNotify),
1406 "ui.editor" => Some(ExtensionCapability::UiEditor),
1407 "ui.custom" => Some(ExtensionCapability::UiCustom),
1408 "ui.select" => Some(ExtensionCapability::UiSelect),
1409 "ui.confirm" => Some(ExtensionCapability::UiConfirm),
1410 "ui.input" => Some(ExtensionCapability::UiInput),
1411 "ui.editor_dialog" => Some(ExtensionCapability::UiEditorDialog),
1412 "session" => Some(ExtensionCapability::Session),
1413 "models" => Some(ExtensionCapability::Models),
1414 "events" => Some(ExtensionCapability::Events),
1415 "providers" => Some(ExtensionCapability::Providers),
1416 "provider_calls" => Some(ExtensionCapability::ProviderCalls),
1417 "renderers" => Some(ExtensionCapability::Renderers),
1418 "runtime_actions" => Some(ExtensionCapability::RuntimeActions),
1419 _ => None,
1420 })
1421 {
1422 info.capabilities.insert(capability);
1423 }
1424 info
1425 }
1426}
1427
1428#[derive(Clone)]
1429pub struct JsToolAdapter {
1430 session: JsExtensionSession,
1431 definition: JsToolDefinition,
1432}
1433
1434#[async_trait]
1435impl AgentTool for JsToolAdapter {
1436 fn schema(&self) -> &Tool {
1437 &self.definition.tool
1438 }
1439 fn label(&self) -> &str {
1440 &self.definition.label
1441 }
1442 fn execution_mode(&self) -> ToolExecutionMode {
1443 self.definition.execution_mode
1444 }
1445 async fn execute(
1446 &self,
1447 tool_call_id: &str,
1448 params: serde_json::Value,
1449 signal: CancellationToken,
1450 on_update: Arc<dyn Fn(ToolResultPartial) + Send + Sync>,
1451 ) -> Result<AgentToolResult, AgentError> {
1452 let name = self.definition.name.clone();
1453 let tool_call_id = tool_call_id.to_string();
1454 let transport = self.session.transport.clone();
1455 let update_handler: Arc<dyn Fn(serde_json::Value) + Send + Sync> = {
1456 let on_update = on_update.clone();
1457 Arc::new(move |value| {
1458 if let Ok(partial) = parse_tool_result(value) {
1462 on_update(partial);
1463 }
1464 })
1465 };
1466 transport
1467 .register_tool_update_handler(tool_call_id.clone(), update_handler)
1468 .map_err(AgentError::tool)?;
1469 let pending = match transport.begin_request(
1470 "invoke_tool",
1471 serde_json::json!({"tool": name, "toolCallId": tool_call_id.clone(), "args": params}),
1472 ) {
1473 Ok(pending) => pending,
1474 Err(error) => {
1475 transport.unregister_tool_update_handler(&tool_call_id);
1476 return Err(AgentError::tool(error));
1477 }
1478 };
1479 let request_id = pending.id();
1480 let cancel_transport = transport.clone();
1481 let wait = tokio::task::spawn_blocking(move || pending.wait());
1482 let result = tokio::select! {
1483 result = wait => result
1484 .map_err(|error| AgentError::tool(error.to_string()))
1485 .and_then(|response| response.map_err(AgentError::tool)),
1486 _ = signal.cancelled() => {
1487 let _ = cancel_transport.cancel(request_id);
1488 Err(AgentError::Abort)
1489 }
1490 };
1491 transport.unregister_tool_update_handler(&tool_call_id);
1492 let result = result?;
1493 parse_tool_result(result).map_err(AgentError::tool)
1494 }
1495}
1496
1497fn start_node_transport(
1498 paths: &[PathBuf],
1499 context: &serde_json::Value,
1500 discovery_only: bool,
1501 initial_handlers: &[RuntimeHandler],
1502) -> Result<NodeTransportStartup, String> {
1503 let node = which_node()?;
1504 let host_script = if cfg!(windows) {
1509 Some(write_node_host_script()?)
1510 } else {
1511 None
1512 };
1513 let mut command = Command::new(node);
1514 if let Some(path) = host_script.as_ref() {
1515 command.arg(path);
1516 } else {
1517 command.args(["--input-type=module", "-e", NODE_HOST]);
1518 }
1519 command
1520 .env(
1521 "RPI_JS_EXTENSION_PATHS",
1522 serde_json::to_string(paths).unwrap_or_else(|_| "[]".into()),
1523 )
1524 .env(
1525 "RPI_JS_EXTENSION_CONTEXT",
1526 serde_json::to_string(context).unwrap_or_else(|_| "{}".into()),
1527 )
1528 .stdin(Stdio::piped())
1529 .stdout(Stdio::piped())
1530 .stderr(Stdio::inherit());
1533 if discovery_only {
1534 command.env("RPI_JS_EXTENSION_ONESHOT", "1");
1535 } else {
1536 command
1537 .env("RPI_JS_EXTENSION_SKIP_INITIAL_HOOK", "1")
1538 .env("RPI_JS_EXTENSION_SKIP_INITIAL_DISCOVERY", "1");
1539 }
1540 let mut child: Child = match command.spawn() {
1541 Ok(child) => child,
1542 Err(error) => {
1543 remove_node_host_script(host_script.as_ref());
1544 return Err(format!("could not start Node JS extension host: {error}"));
1545 }
1546 };
1547 let stdin: ChildStdin = match child.stdin.take() {
1548 Some(stdin) => stdin,
1549 None => {
1550 let _ = child.kill();
1551 let _ = child.wait();
1552 remove_node_host_script(host_script.as_ref());
1553 return Err("Node extension host stdin unavailable".into());
1554 }
1555 };
1556 let stdout: ChildStdout = match child.stdout.take() {
1557 Some(stdout) => stdout,
1558 None => {
1559 let _ = child.kill();
1560 let _ = child.wait();
1561 remove_node_host_script(host_script.as_ref());
1562 return Err("Node extension host stdout unavailable".into());
1563 }
1564 };
1565 let result = NodeTransport::start_pending_with_cleanup_and_handlers(
1566 child,
1567 stdin,
1568 stdout,
1569 host_script.clone(),
1570 initial_handlers.to_vec(),
1571 );
1572 if result.is_err() {
1573 remove_node_host_script(host_script.as_ref());
1574 }
1575 result
1576}
1577
1578fn write_node_host_script() -> Result<PathBuf, String> {
1579 let id = NEXT_NODE_HOST_SCRIPT.fetch_add(1, Ordering::Relaxed);
1580 let path = std::env::temp_dir().join(format!("rpi-node-host-{}-{id}.mjs", std::process::id()));
1581 let mut file = OpenOptions::new()
1582 .write(true)
1583 .create_new(true)
1584 .open(&path)
1585 .map_err(|error| format!("could not create Node host script: {error}"))?;
1586 if let Err(error) = file.write_all(NODE_HOST.as_bytes()) {
1587 let _ = std::fs::remove_file(&path);
1588 return Err(format!("could not write Node host script: {error}"));
1589 }
1590 Ok(path)
1591}
1592
1593fn remove_node_host_script(path: Option<&PathBuf>) {
1594 if let Some(path) = path {
1595 let _ = std::fs::remove_file(path);
1596 }
1597}
1598
1599fn which_node() -> Result<String, String> {
1600 for candidate in ["node", "nodejs"] {
1601 if Command::new(candidate)
1602 .arg("--version")
1603 .stdout(Stdio::null())
1604 .stderr(Stdio::null())
1605 .status()
1606 .is_ok_and(|status| status.success())
1607 {
1608 return Ok(candidate.to_string());
1609 }
1610 }
1611 Err("Pi JS/TS extensions require Node.js (node --version was not found)".into())
1612}
1613
1614fn parse_resources(value: Option<&serde_json::Value>) -> JsResources {
1615 let list = |key: &str| {
1616 value
1617 .and_then(|v| v.get(key))
1618 .and_then(serde_json::Value::as_array)
1619 .map(|items| {
1620 items
1621 .iter()
1622 .filter_map(|item| item.as_str().map(PathBuf::from))
1623 .collect()
1624 })
1625 .unwrap_or_default()
1626 };
1627 JsResources {
1628 skill_paths: list("skillPaths"),
1629 prompt_paths: list("promptPaths"),
1630 theme_paths: list("themePaths"),
1631 }
1632}
1633
1634fn parse_tool_result(value: serde_json::Value) -> Result<AgentToolResult, String> {
1635 let mut result = AgentToolResult::default();
1636 if let Some(content) = value.get("content").and_then(serde_json::Value::as_array) {
1637 for block in content {
1638 if block.get("type").and_then(serde_json::Value::as_str) == Some("image") {
1639 result
1640 .content
1641 .push(TextContentOrImage::Image(rpi_ai::types::ImageContent {
1642 kind: rpi_ai::types::ImageContentType,
1643 data: block
1644 .get("data")
1645 .and_then(serde_json::Value::as_str)
1646 .unwrap_or_default()
1647 .into(),
1648 mime_type: block
1649 .get("mimeType")
1650 .and_then(serde_json::Value::as_str)
1651 .unwrap_or("image/png")
1652 .into(),
1653 }));
1654 } else {
1655 result.content.push(TextContentOrImage::text(
1656 block
1657 .get("text")
1658 .and_then(serde_json::Value::as_str)
1659 .unwrap_or_default(),
1660 ));
1661 }
1662 }
1663 } else if let Some(text) = value.as_str() {
1664 result.content.push(TextContentOrImage::text(text));
1665 }
1666 result.details = value
1667 .get("details")
1668 .cloned()
1669 .unwrap_or(serde_json::Value::Null);
1670 result.usage = value
1671 .get("usage")
1672 .cloned()
1673 .filter(|value| !value.is_null())
1674 .and_then(|value| serde_json::from_value(value).ok());
1678 result.added_tool_names = value
1679 .get("addedToolNames")
1680 .and_then(serde_json::Value::as_array)
1681 .map(|values| {
1682 values
1683 .iter()
1684 .filter_map(serde_json::Value::as_str)
1685 .map(ToOwned::to_owned)
1686 .collect()
1687 })
1688 .unwrap_or_default();
1689 result.terminate = value
1690 .get("terminate")
1691 .and_then(serde_json::Value::as_bool)
1692 .unwrap_or(false);
1693 Ok(result)
1694}
1695
1696fn normalize_host_path(path: &PathBuf) -> PathBuf {
1697 if cfg!(windows) {
1698 let text = path.to_string_lossy();
1699 if let Some(stripped) = text.strip_prefix("\\\\?\\") {
1700 return PathBuf::from(stripped);
1701 }
1702 }
1703 path.clone()
1704}
1705
1706#[cfg(test)]
1707mod tests {
1708 use super::*;
1709 use crate::extension_api::ExtensionBackend;
1710
1711 #[test]
1712 fn lazy_runtime_context_merges_capabilities_before_start() {
1713 let transport = LazyNodeTransport::new(Vec::new(), serde_json::json!({}));
1714 transport
1715 .set_runtime_context(serde_json::json!({
1716 "capabilities": ["ui.custom"],
1717 "session": {"id": "first"}
1718 }))
1719 .unwrap();
1720 transport
1721 .set_runtime_context(serde_json::json!({
1722 "capabilities": ["ui.dialog"],
1723 "session": {"id": "second"}
1724 }))
1725 .unwrap();
1726 let pending = transport.pending_context.lock().unwrap().clone().unwrap();
1727 assert_eq!(
1728 pending["capabilities"],
1729 serde_json::json!(["ui.custom", "ui.dialog"])
1730 );
1731 assert_eq!(pending["session"]["id"], "second");
1732 }
1733
1734 #[test]
1735 fn lazy_transport_stops_host_when_final_owner_drops() {
1736 let temp = tempfile::tempdir().unwrap();
1737 let path = temp.path().join("drop.js");
1738 std::fs::write(
1739 &path,
1740 "export default (pi) => pi.registerCommand('ping', async () => ({ text: 'pong' }));",
1741 )
1742 .unwrap();
1743 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
1744 let live = session.transport.ensure().unwrap();
1745 assert!(!live.is_shutdown());
1746
1747 let retained_owner = session.transport.clone();
1751 drop(session);
1752 assert!(!live.is_shutdown());
1753 drop(retained_owner);
1754
1755 assert!(
1756 live.is_shutdown(),
1757 "the final LazyNodeTransport owner must stop the Node host"
1758 );
1759 assert!(live
1760 .request(
1761 "invoke_command",
1762 serde_json::json!({"command": "ping", "args": ""})
1763 )
1764 .is_err());
1765 }
1766
1767 #[test]
1768 fn parse_tool_result_preserves_supported_metadata_and_ignores_unknown_usage() {
1769 let result = parse_tool_result(serde_json::json!({
1770 "content": [{"type": "text", "text": "ok"}],
1771 "details": {"source": "js"},
1772 "usage": {"totalTokens": "later-version"},
1773 "addedToolNames": ["next", 7],
1774 }))
1775 .unwrap();
1776 assert!(result.usage.is_none());
1777 assert_eq!(result.added_tool_names, ["next"]);
1778 assert_eq!(result.details["source"], "js");
1779 }
1780
1781 #[test]
1782 fn loads_javascript_tool_and_invokes_it() {
1783 let temp = tempfile::tempdir().unwrap();
1784 let path = temp.path().join("extension.js");
1785 std::fs::write(
1786 &path,
1787 "export default (pi) => pi.registerTool({name: 'hello', description: 'test', executionMode: 'sequential', parameters: {type: 'object'}, async execute() { return {content: [{type: 'text', text: 'ok'}], details: {}}; }});",
1788 )
1789 .unwrap();
1790 let session = JsExtensionSession::load(&[path], true).unwrap().unwrap();
1791 let tool = session.tools().next().unwrap();
1792 assert_eq!(tool.schema().name, "hello");
1793 assert_eq!(tool.execution_mode(), ToolExecutionMode::Sequential);
1794 let value = session
1795 .invoke(
1796 "invoke_tool",
1797 serde_json::json!({"tool":"hello", "args":{}}),
1798 )
1799 .unwrap();
1800 assert_eq!(value["content"][0]["text"], "ok");
1801 }
1802
1803 #[test]
1804 fn failed_extension_rolls_back_registrations_and_keeps_loading() {
1805 let temp = tempfile::tempdir().unwrap();
1806 let broken = temp.path().join("a-broken.js");
1807 let healthy = temp.path().join("b-healthy.js");
1808 std::fs::write(
1809 &broken,
1810 r#"export default (pi) => {
1811 pi.registerCommand('leaked-command', async () => ({ text: 'leaked' }));
1812 pi.registerTool({
1813 name: 'leaked-tool',
1814 description: 'must be rolled back',
1815 parameters: { type: 'object', properties: {} },
1816 async execute() { return { text: 'leaked' }; }
1817 });
1818 throw new Error('broken factory');
1819 };"#,
1820 )
1821 .unwrap();
1822 std::fs::write(
1823 &healthy,
1824 "export default (pi) => pi.registerCommand('healthy', async () => ({ text: 'pong' }));",
1825 )
1826 .unwrap();
1827
1828 let session = JsExtensionSession::load(&[broken, healthy], false)
1829 .unwrap()
1830 .unwrap();
1831 assert_eq!(session.commands, ["healthy"]);
1832 assert!(!session
1833 .tool_names()
1834 .iter()
1835 .any(|name| name == "leaked-tool"));
1836 let value = session.invoke_command("healthy", "").unwrap();
1837 assert_eq!(value["result"]["text"], "pong");
1838 assert!(session.invoke_command("leaked-command", "").is_err());
1839 }
1840
1841 #[test]
1842 fn failed_extension_rollback_preserves_existing_event_unsubscribe() {
1843 let temp = tempfile::tempdir().unwrap();
1844 let healthy = temp.path().join("a-healthy.js");
1845 let broken = temp.path().join("b-broken.js");
1846 std::fs::write(
1847 &healthy,
1848 r#"export default (pi) => {
1849 let calls = 0;
1850 const off = pi.events.on('probe', () => { calls += 1; });
1851 pi.registerCommand('unsubscribe', async () => {
1852 off();
1853 await pi.events.emit('probe');
1854 return { text: String(calls) };
1855 });
1856 };"#,
1857 )
1858 .unwrap();
1859 std::fs::write(
1860 &broken,
1861 r#"export default (pi) => {
1862 pi.events.on('probe', () => {});
1863 throw new Error('broken factory');
1864 };"#,
1865 )
1866 .unwrap();
1867
1868 let session = JsExtensionSession::load(&[healthy, broken], false)
1869 .unwrap()
1870 .unwrap();
1871 let value = session.invoke_command("unsubscribe", "").unwrap();
1872 assert_eq!(value["result"]["text"], "0");
1873 }
1874
1875 #[test]
1876 fn directory_discovery_does_not_recurse_beyond_one_child_level() {
1877 let temp = tempfile::tempdir().unwrap();
1878 let root = temp.path().join("extensions");
1879 let deep = root.join("child/deep");
1880 std::fs::create_dir_all(&deep).unwrap();
1881 std::fs::write(
1882 root.join("direct.js"),
1883 "export default (pi) => pi.registerCommand('direct', async () => ({ text: 'ok' }));",
1884 )
1885 .unwrap();
1886 std::fs::write(
1887 deep.join("index.js"),
1888 "export default (pi) => pi.registerCommand('too-deep', async () => ({ text: 'bad' }));",
1889 )
1890 .unwrap();
1891
1892 let session = JsExtensionSession::load(&[root], false).unwrap().unwrap();
1893 assert_eq!(session.commands, ["direct"]);
1894 }
1895
1896 #[test]
1897 fn directory_manifest_prefers_rpi_extension_entries() {
1898 let temp = tempfile::tempdir().unwrap();
1899 let root = temp.path().join("package");
1900 std::fs::create_dir_all(&root).unwrap();
1901 std::fs::write(
1902 root.join("package.json"),
1903 r#"{"pi":{"extensions":["pi.js"]},"rpi":{"extensions":["rpi.js"]}}"#,
1904 )
1905 .unwrap();
1906 std::fs::write(
1907 root.join("pi.js"),
1908 "export default (pi) => pi.registerCommand('pi-entry', async () => ({ text: 'pi' }));",
1909 )
1910 .unwrap();
1911 std::fs::write(
1912 root.join("rpi.js"),
1913 "export default (pi) => pi.registerCommand('rpi-entry', async () => ({ text: 'rpi' }));",
1914 )
1915 .unwrap();
1916
1917 let session = JsExtensionSession::load(&[root], false).unwrap().unwrap();
1918 assert_eq!(session.commands, ["rpi-entry"]);
1919 }
1920
1921 #[tokio::test]
1922 async fn javascript_tool_forwards_on_update_partials() {
1923 let temp = tempfile::tempdir().unwrap();
1924 let path = temp.path().join("progress.js");
1925 std::fs::write(
1926 &path,
1927 r#"export default (pi) => pi.registerTool({
1928 name: 'progress',
1929 description: 'emit progress',
1930 parameters: { type: 'object', properties: {} },
1931 async execute(_id, _args, _signal, onUpdate) {
1932 onUpdate({ content: [{ type: 'text', text: 'first' }], details: { step: 1 } });
1933 await new Promise(resolve => setTimeout(resolve, 20));
1934 onUpdate({ content: [{ type: 'text', text: 'second' }], details: { step: 2 } });
1935 return {
1936 content: [{ type: 'text', text: 'done' }],
1937 details: {},
1938 usage: {
1939 input: 1, output: 2, cacheRead: 3, cacheWrite: 4,
1940 totalTokens: 10,
1941 cost: { input: 0.1, output: 0.2, cacheRead: 0, cacheWrite: 0, total: 0.3 }
1942 },
1943 addedToolNames: ['follow_up']
1944 };
1945 }
1946 });"#,
1947 )
1948 .unwrap();
1949 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
1950 let updates = Arc::new(Mutex::new(Vec::<String>::new()));
1951 let observed = updates.clone();
1952 let tool = session.tools().next().expect("registered JS tool");
1953 let result = tool
1954 .execute(
1955 "progress-call",
1956 serde_json::json!({}),
1957 CancellationToken::new(),
1958 Arc::new(move |partial| {
1959 let text = partial
1960 .content
1961 .first()
1962 .and_then(|content| match content {
1963 TextContentOrImage::Text(text) => Some(text.text.clone()),
1964 TextContentOrImage::Image(_) => None,
1965 })
1966 .unwrap_or_default();
1967 observed.lock().unwrap().push(text);
1968 }),
1969 )
1970 .await
1971 .unwrap();
1972 assert_eq!(
1973 result.content.first().and_then(|content| match content {
1974 TextContentOrImage::Text(text) => Some(text.text.as_str()),
1975 TextContentOrImage::Image(_) => None,
1976 }),
1977 Some("done")
1978 );
1979 assert_eq!(
1980 result.usage.as_ref().map(|usage| usage.total_tokens),
1981 Some(10)
1982 );
1983 assert_eq!(result.added_tool_names, ["follow_up"]);
1984 assert_eq!(updates.lock().unwrap().as_slice(), ["first", "second"]);
1985 }
1986
1987 #[tokio::test]
1988 async fn javascript_tool_receives_ui_context() {
1989 let temp = tempfile::tempdir().unwrap();
1990 let path = temp.path().join("ui-tool.js");
1991 std::fs::write(
1992 &path,
1993 r#"export default (pi) => pi.registerTool({
1994 name: 'ui_probe',
1995 description: 'verify the Pi tool context',
1996 parameters: { type: 'object', properties: {} },
1997 async execute(_toolCallId, _args, _signal, _onUpdate, ctx) {
1998 if (ctx?.hasUI !== true) throw new Error('tool context missing hasUI');
1999 if (typeof ctx?.ui?.custom !== 'function') throw new Error('tool context missing ui.custom');
2000 if (!ctx.capabilities?.has('ui.custom')) throw new Error('tool context missing ui.custom capability');
2001 return { content: [{ type: 'text', text: 'ui-context-ok' }], details: {} };
2002 }
2003 });"#,
2004 )
2005 .unwrap();
2006 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2007 session
2011 .set_runtime_context(serde_json::json!({
2012 "mode": "tui",
2013 "hasUI": true,
2014 "capabilities": ["ui.custom"],
2015 }))
2016 .unwrap();
2017
2018 let tool = session.tools().next().expect("registered JS tool");
2019 let result = tool
2020 .execute(
2021 "tool-call-1",
2022 serde_json::json!({}),
2023 CancellationToken::new(),
2024 Arc::new(|_| {}),
2025 )
2026 .await
2027 .expect("tool should receive a UI context");
2028 match result.content.first() {
2029 Some(TextContentOrImage::Text(text)) => assert_eq!(text.text, "ui-context-ok"),
2030 other => panic!("unexpected tool content: {other:?}"),
2031 }
2032 }
2033
2034 #[test]
2035 fn loads_typescript_when_node_supports_type_stripping() {
2036 let temp = tempfile::tempdir().unwrap();
2037 let path = temp.path().join("extension.ts");
2038 std::fs::write(
2039 &path,
2040 "export default (pi: any) => pi.registerTool({name: 'typed', description: 'test', parameters: {type: 'object'}, async execute() { return {content: [{type: 'text', text: 'typed'}]}; }});",
2041 )
2042 .unwrap();
2043 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2044 assert_eq!(session.tools().next().unwrap().schema().name, "typed");
2045 }
2046
2047 #[test]
2048 fn command_context_roundtrips_editor_and_capabilities() {
2049 let temp = tempfile::tempdir().unwrap();
2050 let path = temp.path().join("command.js");
2051 std::fs::write(
2052 &path,
2053 "export default (pi) => pi.registerCommand('edit', async (_args, ctx) => { if (!ctx.capabilities.has('ui.editor')) throw new Error('missing capability'); if (ctx.model?.id !== 'model') throw new Error('missing model'); if (ctx.modelRegistry.getAll().length !== 1) throw new Error('missing catalog'); if (ctx.sessionManager.getSessionId() !== 'session-1' || ctx.sessionManager.getLeafId() !== 'leaf-1' || !ctx.sessionManager.getEntry('entry-1')) throw new Error('missing session'); ctx.ui.setEditorText(ctx.ui.getEditorText() + '!'); ctx.ui.notify('updated', 'info'); return {text: 'done'}; });",
2054 )
2055 .unwrap();
2056 let session = JsExtensionSession::load_with_context(
2057 &[path],
2058 false,
2059 serde_json::json!({
2060 "currentModel":{"id":"model","provider":"anthropic"},
2061 "models":[{"id":"model","provider":"anthropic"}]
2062 }),
2063 )
2064 .unwrap()
2065 .unwrap();
2066 assert!(session.transport.transport.lock().unwrap().is_none());
2067 session
2068 .set_runtime_context(serde_json::json!({
2069 "session":{"id":"session-1","leafId":"leaf-1","entries":[{"id":"entry-1"}]}
2070 }))
2071 .unwrap();
2072 assert!(session.transport.transport.lock().unwrap().is_none());
2073 let value = session
2074 .invoke_command_with_context("edit", "", serde_json::json!({"editorText":"draft"}))
2075 .unwrap();
2076 assert_eq!(value["result"]["text"], "done");
2077 assert_eq!(value["editorText"], "draft!");
2078 assert_eq!(value["notifications"][0]["message"], "updated");
2079 assert!(session
2080 .backend_info()
2081 .supports(crate::extension_api::ExtensionCapability::UiEditor));
2082 }
2083
2084 #[test]
2085 fn command_context_is_non_ui_until_a_tui_bridge_is_installed() {
2086 let temp = tempfile::tempdir().unwrap();
2087 let path = temp.path().join("ui-mode.js");
2088 std::fs::write(
2089 &path,
2090 r#"export default (pi) => pi.registerCommand('mode', async (_args, ctx) => ({
2091 hasUI: ctx.hasUI,
2092 mode: ctx.mode,
2093 hasCustom: typeof ctx.ui.custom === 'function'
2094 }));"#,
2095 )
2096 .unwrap();
2097 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2098 let plain = session.invoke_command("mode", "").unwrap();
2099 assert_eq!(plain["result"]["hasUI"], false);
2100 assert_eq!(plain["result"]["mode"], "print");
2101 assert_eq!(plain["result"]["hasCustom"], true);
2102
2103 session
2104 .install_ui_dialog_runtime(Arc::new(|action, _args| {
2105 Err(format!("unsupported capability: {action}"))
2106 }))
2107 .unwrap();
2108 let tui = session.invoke_command("mode", "").unwrap();
2109 assert_eq!(tui["result"]["hasUI"], true);
2110 assert_eq!(tui["result"]["mode"], "tui");
2111 }
2112
2113 #[test]
2114 fn before_agent_start_reconciles_js_active_tools_when_ui_changes() {
2115 let temp = tempfile::tempdir().unwrap();
2116 let path = temp.path().join("active-tools.js");
2117 std::fs::write(
2118 &path,
2119 r#"export default (pi) => {
2120 pi.registerTool({
2121 name: 'ask_user_question',
2122 description: 'probe',
2123 parameters: { type: 'object', properties: {} },
2124 async execute() { return { content: [{ type: 'text', text: 'ok' }] }; }
2125 });
2126 pi.on('before_agent_start', (_event, ctx) => {
2127 const active = pi.getActiveTools();
2128 if (!ctx.hasUI) {
2129 pi.setActiveTools(active.filter((name) => name !== 'ask_user_question'));
2130 } else if (!active.includes('ask_user_question')) {
2131 pi.setActiveTools([...active, 'ask_user_question']);
2132 }
2133 });
2134 };"#,
2135 )
2136 .unwrap();
2137 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2138 assert!(!session
2139 .active_tools()
2140 .unwrap()
2141 .iter()
2142 .any(|name| name == "ask_user_question"));
2143
2144 let tui = Arc::new(rpi_tui::TuiAltScreen::new(
2145 Box::new(rpi_tui::ProcessTerminal::new()),
2146 true,
2147 None,
2148 ));
2149 session.install_ui_runtime(tui).unwrap();
2150 assert!(session.transport.transport.lock().unwrap().is_none());
2151 session.ensure_runtime().unwrap();
2152 assert!(session
2153 .active_tools()
2154 .unwrap()
2155 .iter()
2156 .any(|name| name == "ask_user_question"));
2157 }
2158
2159 #[test]
2160 fn persistent_host_runs_before_agent_start_without_pending_context() {
2161 let temp = tempfile::tempdir().unwrap();
2162 let marker = temp.path().join("before-agent-start-count");
2163 let path = temp.path().join("before-agent-start.js");
2164 let marker_literal = serde_json::to_string(&marker.to_string_lossy()).unwrap();
2165 std::fs::write(&marker, "0").unwrap();
2166 std::fs::write(
2167 &path,
2168 format!(
2169 r#"import fs from 'node:fs';
2170 export default (pi) => {{
2171 pi.registerCommand('ping', async () => ({{ text: 'pong' }}));
2172 pi.on('before_agent_start', () => {{
2173 const file = {marker};
2174 const count = Number(fs.readFileSync(file, 'utf8') || '0') + 1;
2175 fs.writeFileSync(file, String(count));
2176 }});
2177 }};"#,
2178 marker = marker_literal,
2179 ),
2180 )
2181 .unwrap();
2182
2183 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2184 assert_eq!(
2185 std::fs::read_to_string(&marker).unwrap(),
2186 "1",
2187 "discovery host should run the lifecycle hook once"
2188 );
2189
2190 session.invoke_command("ping", "").unwrap();
2193 assert_eq!(
2194 std::fs::read_to_string(&marker).unwrap(),
2195 "2",
2196 "persistent host skipped before_agent_start when no context was queued"
2197 );
2198 }
2199
2200 #[test]
2201 fn prepare_for_prompt_runs_lifecycle_once_per_prompt() {
2202 let temp = tempfile::tempdir().unwrap();
2203 let marker = temp.path().join("prompt-lifecycle-count");
2204 let path = temp.path().join("prompt-lifecycle.js");
2205 let marker_literal = serde_json::to_string(&marker.to_string_lossy()).unwrap();
2206 std::fs::write(&marker, "0").unwrap();
2207 std::fs::write(
2208 &path,
2209 format!(
2210 r#"import fs from 'node:fs';
2211 export default (pi) => {{
2212 pi.on('before_agent_start', () => {{
2213 const file = {marker};
2214 const count = Number(fs.readFileSync(file, 'utf8') || '0') + 1;
2215 fs.writeFileSync(file, String(count));
2216 }});
2217 }};"#,
2218 marker = marker_literal,
2219 ),
2220 )
2221 .unwrap();
2222
2223 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2224 assert_eq!(std::fs::read_to_string(&marker).unwrap(), "1");
2225
2226 session.prepare_for_prompt().unwrap();
2227 assert_eq!(std::fs::read_to_string(&marker).unwrap(), "2");
2228
2229 session.prepare_for_prompt().unwrap();
2230 assert_eq!(std::fs::read_to_string(&marker).unwrap(), "3");
2231 }
2232
2233 #[test]
2234 fn concurrent_runtime_context_hooks_apply_latest_active_tools() {
2235 let temp = tempfile::tempdir().unwrap();
2236 let marker = temp.path().join("context-order.log");
2237 let marker_literal = serde_json::to_string(&marker.to_string_lossy()).unwrap();
2238 let path = temp.path().join("context-order.js");
2239 let source = r#"import fs from 'node:fs';
2240 export default (pi) => {
2241 pi.registerTool({ name: 'v1', description: 'v1', parameters: { type: 'object', properties: {} }, async execute() { return { text: 'v1' }; } });
2242 pi.registerTool({ name: 'v2', description: 'v2', parameters: { type: 'object', properties: {} }, async execute() { return { text: 'v2' }; } });
2243 pi.on('before_agent_start', async (_event, ctx) => {
2244 const version = ctx.sessionManager.getSessionId();
2245 fs.writeFileSync(__MARKER__, version);
2246 if (version === 'v1') await new Promise(resolve => setTimeout(resolve, 200));
2247 pi.setActiveTools([version]);
2248 });
2249 };"#
2250 .replace("__MARKER__", &marker_literal);
2251 std::fs::write(&path, source).unwrap();
2252
2253 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2254 session.ensure_runtime().unwrap();
2255
2256 let first = session.clone();
2257 let first_worker = std::thread::spawn(move || {
2258 first.set_runtime_context(serde_json::json!({ "session": { "id": "v1" } }))
2259 });
2260 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
2261 while std::fs::read_to_string(&marker).ok().as_deref() != Some("v1")
2262 && std::time::Instant::now() < deadline
2263 {
2264 std::thread::sleep(std::time::Duration::from_millis(5));
2265 }
2266 assert_eq!(std::fs::read_to_string(&marker).unwrap(), "v1");
2267
2268 let second = session.clone();
2269 let second_worker = std::thread::spawn(move || {
2270 second.set_runtime_context(serde_json::json!({ "session": { "id": "v2" } }))
2271 });
2272 first_worker.join().unwrap().unwrap();
2273 second_worker.join().unwrap().unwrap();
2274
2275 assert_eq!(session.active_tools().unwrap(), ["v2"]);
2276 }
2277
2278 #[test]
2279 fn stale_runtime_context_revision_cannot_overwrite_newer_state() {
2280 let temp = tempfile::tempdir().unwrap();
2281 let path = temp.path().join("context-revision.js");
2282 std::fs::write(
2283 &path,
2284 r#"export default (pi) => {
2285 pi.on('before_agent_start', (_event, ctx) => {
2286 pi.setActiveTools([ctx.sessionManager.getSessionId()]);
2287 });
2288 };"#,
2289 )
2290 .unwrap();
2291 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2292 let transport = session.transport.ensure().unwrap();
2293
2294 let newer = transport
2295 .request(
2296 "set_runtime_context",
2297 serde_json::json!({
2298 "revision": 2,
2299 "context": {"session": {"id": "newer"}}
2300 }),
2301 )
2302 .unwrap();
2303 assert_eq!(newer["activeTools"], serde_json::json!(["newer"]));
2304
2305 let stale = transport
2306 .request(
2307 "set_runtime_context",
2308 serde_json::json!({
2309 "revision": 1,
2310 "context": {"session": {"id": "stale"}}
2311 }),
2312 )
2313 .unwrap();
2314 assert_eq!(stale["activeTools"], serde_json::json!(["newer"]));
2315 }
2316
2317 #[test]
2318 fn restarted_host_replays_context_queued_before_first_start() {
2319 let temp = tempfile::tempdir().unwrap();
2320 let path = temp.path().join("context-restart.js");
2321 std::fs::write(
2322 &path,
2323 r#"export default (pi) => {
2324 pi.registerCommand('session-id', async (_args, ctx) => ({
2325 text: ctx.sessionManager.getSessionId()
2326 }));
2327 };"#,
2328 )
2329 .unwrap();
2330 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2331 session
2332 .set_runtime_context(serde_json::json!({"session": {"id": "latest"}}))
2333 .unwrap();
2334
2335 let first = session.invoke_command("session-id", "").unwrap();
2336 assert_eq!(first["result"]["text"], "latest");
2337 let first_transport = session.transport.live_transport().unwrap().unwrap();
2338 first_transport.shutdown();
2339
2340 let restarted = session.invoke_command("session-id", "").unwrap();
2341 assert_eq!(restarted["result"]["text"], "latest");
2342 }
2343
2344 #[test]
2345 fn failed_persistent_startup_requeues_initial_runtime_context() {
2346 let temp = tempfile::tempdir().unwrap();
2347 let path = temp.path().join("startup-failure.js");
2348 std::fs::write(
2349 &path,
2350 r#"export default async (pi) => {
2351 // Discovery is deliberately healthy, while the persistent
2352 // process exits before publishing its initialization envelope.
2353 // A thrown factory error is isolated per extension by the host
2354 // and therefore is not a transport-startup failure.
2355 if (process.env.RPI_JS_EXTENSION_ONESHOT !== '1') {
2356 process.exit(17);
2357 }
2358 pi.registerCommand('ping', async () => ({ text: 'pong' }));
2359 };"#,
2360 )
2361 .unwrap();
2362 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2363 let context = serde_json::json!({
2364 "mode": "tui",
2365 "hasUI": true,
2366 "capabilities": ["ui.custom"],
2367 "session": {"id": "startup-session"},
2368 });
2369 session.set_runtime_context(context.clone()).unwrap();
2370
2371 let error = session.invoke_command("ping", "").unwrap_err();
2372 assert!(
2373 error.contains("exited before initialization")
2374 || error.contains("exited without a response")
2375 || error.contains("persistent host startup failure"),
2376 "unexpected startup error: {error}"
2377 );
2378 let pending = session
2379 .transport
2380 .pending_context
2381 .lock()
2382 .unwrap()
2383 .clone()
2384 .expect("failed startup must preserve runtime context");
2385 assert_eq!(pending["mode"], "tui");
2386 assert_eq!(pending["session"]["id"], "startup-session");
2387 assert_eq!(pending["capabilities"], serde_json::json!(["ui.custom"]));
2388 }
2389
2390 #[test]
2391 fn dialog_ui_roundtrips_through_runtime_handler() {
2392 let temp = tempfile::tempdir().unwrap();
2393 let path = temp.path().join("dialog.js");
2394 std::fs::write(
2395 &path,
2396 r#"export default (pi) => {
2397 pi.registerCommand('select', async (_args, ctx) => ({ text: await ctx.ui.select('Pick one', ['red', 'blue']) }));
2398 pi.registerCommand('confirm', async (_args, ctx) => ({ text: String(await ctx.ui.confirm('Continue?', 'Do it?')) }));
2399 pi.registerCommand('input', async (_args, ctx) => ({ text: await ctx.ui.input('Name', 'placeholder') }));
2400 pi.registerCommand('editor', async (_args, ctx) => ({ text: await ctx.ui.editor('Notes', 'prefilled') }));
2401 };"#,
2402 )
2403 .unwrap();
2404 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2405 let actions = Arc::new(Mutex::new(Vec::<(String, serde_json::Value)>::new()));
2406 let observed = actions.clone();
2407 session
2408 .install_ui_dialog_runtime(Arc::new(move |action, args| {
2409 observed
2410 .lock()
2411 .unwrap()
2412 .push((action.to_string(), args.clone()));
2413 match action {
2414 "ui.dialog" => match args.get("method").and_then(serde_json::Value::as_str) {
2415 Some("select") => Ok(serde_json::json!({"value":"blue"})),
2416 Some("confirm") => Ok(serde_json::json!({"confirmed":true})),
2417 Some("input") => Ok(serde_json::json!({"value":"Ada"})),
2418 Some("editor") => Ok(serde_json::json!({"value":"edited"})),
2419 _ => Err("unknown dialog method".into()),
2420 },
2421 _ => Err(format!("unsupported capability: {action}")),
2422 }
2423 }))
2424 .unwrap();
2425
2426 let backend = session.backend_info();
2427 assert!(backend.supports(crate::extension_api::ExtensionCapability::UiSelect));
2428 assert!(backend.supports(crate::extension_api::ExtensionCapability::UiConfirm));
2429 assert!(backend.supports(crate::extension_api::ExtensionCapability::UiInput));
2430 assert!(backend.supports(crate::extension_api::ExtensionCapability::UiEditorDialog));
2431
2432 assert_eq!(
2433 session.invoke_command("select", "").unwrap()["result"]["text"],
2434 "blue"
2435 );
2436 assert_eq!(
2437 session.invoke_command("confirm", "").unwrap()["result"]["text"],
2438 "true"
2439 );
2440 assert_eq!(
2441 session.invoke_command("input", "").unwrap()["result"]["text"],
2442 "Ada"
2443 );
2444 assert_eq!(
2445 session.invoke_command("editor", "").unwrap()["result"]["text"],
2446 "edited"
2447 );
2448
2449 let actions = actions.lock().unwrap();
2450 assert_eq!(actions.len(), 4);
2451 assert!(actions.iter().all(|(action, _)| action == "ui.dialog"));
2452 assert_eq!(actions[0].1["method"], "select");
2453 assert_eq!(actions[0].1["options"], serde_json::json!(["red", "blue"]));
2454 assert_eq!(actions[2].1["placeholder"], "placeholder");
2455 assert_eq!(actions[3].1["prefill"], "prefilled");
2456 }
2457
2458 #[test]
2459 fn dialog_timeout_sends_cancel_request() {
2460 let temp = tempfile::tempdir().unwrap();
2461 let path = temp.path().join("dialog-timeout.js");
2462 std::fs::write(
2463 &path,
2464 r#"export default (pi) => pi.registerCommand('confirm', async (_args, ctx) => ({ text: String(await ctx.ui.confirm('Continue?', 'wait', { timeout: 20 })) }));"#,
2465 )
2466 .unwrap();
2467 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2468 let actions = Arc::new(Mutex::new(Vec::<String>::new()));
2469 let observed = actions.clone();
2470 session
2471 .install_ui_dialog_runtime(Arc::new(move |action, _args| {
2472 observed.lock().unwrap().push(action.to_string());
2473 match action {
2474 "ui.dialog" => {
2475 std::thread::sleep(std::time::Duration::from_millis(100));
2476 Ok(serde_json::json!({"confirmed":true}))
2477 }
2478 "ui.dialog.cancel" => Ok(serde_json::json!(true)),
2479 _ => Err(format!("unsupported capability: {action}")),
2480 }
2481 }))
2482 .unwrap();
2483 let value = session.invoke_command("confirm", "").unwrap();
2484 assert_eq!(value["result"]["text"], "false");
2485 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
2486 while std::time::Instant::now() < deadline {
2487 let complete = {
2488 let actions = actions.lock().unwrap();
2489 actions.iter().any(|action| action == "ui.dialog")
2490 && actions.iter().any(|action| action == "ui.dialog.cancel")
2491 };
2492 if complete {
2493 break;
2494 }
2495 std::thread::sleep(std::time::Duration::from_millis(10));
2496 }
2497 let actions = actions.lock().unwrap();
2498 assert!(actions.iter().any(|action| action == "ui.dialog"));
2499 assert!(actions.iter().any(|action| action == "ui.dialog.cancel"));
2500 }
2501
2502 #[test]
2503 fn runtime_request_roundtrips_through_rust_handler() {
2504 let temp = tempfile::tempdir().unwrap();
2505 let path = temp.path().join("runtime.js");
2506 std::fs::write(
2507 &path,
2508 "export default (pi) => pi.registerCommand('runtime', async () => ({text: await pi.runtimeRequest('echo', {value: 7})}));",
2509 )
2510 .unwrap();
2511 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2512 session
2513 .set_runtime_handler(Arc::new(|action, args| {
2514 if action == "echo" {
2515 Ok(args.get("value").cloned().unwrap_or_default())
2516 } else {
2517 Err(format!("unsupported capability: {action}"))
2518 }
2519 }))
2520 .unwrap();
2521 let value = session.invoke_command("runtime", "").unwrap();
2522 assert_eq!(value["result"]["text"], 7);
2523 }
2524
2525 #[test]
2526 fn startup_runtime_request_is_not_mistaken_for_init_and_uses_initial_handler() {
2527 let temp = tempfile::tempdir().unwrap();
2528 let path = temp.path().join("startup-runtime.js");
2529 std::fs::write(
2530 &path,
2531 r#"export default async (pi) => {
2532 const value = await pi.runtimeRequest('startup.echo', { value: 'persistent' })
2533 .catch(() => 'discovery');
2534 pi.registerCommand('startup', async () => ({ text: String(value) }));
2535 };"#,
2536 )
2537 .unwrap();
2538
2539 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2543 session
2544 .set_runtime_handler(Arc::new(|action, args| {
2545 if action == "startup.echo" {
2546 Ok(args.get("value").cloned().unwrap_or_default())
2547 } else {
2548 Err(format!("unsupported capability: {action}"))
2549 }
2550 }))
2551 .unwrap();
2552
2553 let value = session.invoke_command("startup", "").unwrap();
2556 assert_eq!(value["result"]["text"], "persistent");
2557 }
2558
2559 #[test]
2560 fn provider_runtime_adapts_node_model_registry_to_rust_provider() {
2561 use rpi_ai::providers::faux::{FauxProvider, FauxScript};
2562
2563 let temp = tempfile::tempdir().unwrap();
2564 let path = temp.path().join("provider.js");
2565 std::fs::write(
2566 &path,
2567 r#"export default (pi) => pi.registerCommand('complete', async (_args, ctx) => {
2568 const model = ctx.model;
2569 const provider = ctx.modelRegistry.getProvider(model.provider);
2570 if (!provider) throw new Error('provider missing');
2571 const stream = provider.streamSimple(model, { messages: [] }, {});
2572 let eventCount = 0;
2573 for await (const event of stream) eventCount += 1;
2574 const message = await stream.result();
2575 return { text: message.content?.[0]?.text || '', stopReason: message.stopReason, eventCount };
2576 });"#,
2577 )
2578 .unwrap();
2579
2580 let runtime = tokio::runtime::Runtime::new().unwrap();
2581 let provider = FauxProvider::new(FauxScript::new().with_text("from rust"));
2582 let model = provider.default_model().clone();
2583 let session = JsExtensionSession::load_with_context(
2584 &[path],
2585 false,
2586 serde_json::json!({
2587 "currentModel": model,
2588 "models": [model],
2589 }),
2590 )
2591 .unwrap()
2592 .unwrap();
2593 session
2594 .enable_provider_runtime(provider.clone(), runtime.handle().clone())
2595 .unwrap();
2596
2597 let value = session.invoke_command("complete", "").unwrap();
2600 assert_eq!(value["result"]["text"], "from rust");
2601 assert_eq!(value["result"]["stopReason"], "stop");
2602 assert!(value["result"]["eventCount"].as_u64().unwrap() >= 2);
2603 assert_eq!(
2604 provider
2605 .state()
2606 .call_count
2607 .load(std::sync::atomic::Ordering::Relaxed),
2608 1
2609 );
2610 drop(session);
2611 runtime.shutdown_timeout(std::time::Duration::from_secs(1));
2612 }
2613
2614 #[test]
2615 fn node_transport_multiplexes_out_of_order_command_responses() {
2616 let temp = tempfile::tempdir().unwrap();
2617 let path = temp.path().join("concurrent.js");
2618 std::fs::write(
2619 &path,
2620 r#"export default (pi) => {
2621 pi.registerCommand('slow', async () => {
2622 await new Promise(resolve => setTimeout(resolve, 300));
2623 return { text: 'slow' };
2624 });
2625 pi.registerCommand('fast', async () => ({ text: 'fast' }));
2626 };"#,
2627 )
2628 .unwrap();
2629 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2630 assert!(session.transport.transport.lock().unwrap().is_none());
2631 let (sender, receiver) = std::sync::mpsc::channel();
2632
2633 let slow = session.clone();
2634 let slow_sender = sender.clone();
2635 std::thread::spawn(move || {
2636 let value = slow.invoke_command("slow", "").unwrap();
2637 slow_sender.send(value["result"]["text"].clone()).unwrap();
2638 });
2639 std::thread::sleep(std::time::Duration::from_millis(50));
2640 let fast = session.clone();
2641 std::thread::spawn(move || {
2642 let value = fast.invoke_command("fast", "").unwrap();
2643 sender.send(value["result"]["text"].clone()).unwrap();
2644 });
2645
2646 let first = receiver
2647 .recv_timeout(std::time::Duration::from_secs(2))
2648 .expect("fast request should not wait for the slow request");
2649 assert_eq!(first, serde_json::json!("fast"));
2650 assert_eq!(receiver.recv().unwrap(), serde_json::json!("slow"));
2651 }
2652
2653 #[test]
2654 fn shutting_down_session_wakes_detached_command_and_blocks_restart() {
2655 let temp = tempfile::tempdir().unwrap();
2656 let path = temp.path().join("shutdown.js");
2657 std::fs::write(
2658 &path,
2659 r#"export default (pi) => pi.registerCommand('wait', async () => {
2660 await new Promise(resolve => setTimeout(resolve, 10000));
2661 return { text: 'late' };
2662 });"#,
2663 )
2664 .unwrap();
2665 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2666 let worker_session = session.clone();
2667 let (sender, receiver) = std::sync::mpsc::channel();
2668 let worker = std::thread::spawn(move || {
2669 sender
2670 .send(worker_session.invoke_command("wait", ""))
2671 .unwrap();
2672 });
2673
2674 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
2677 while session.transport.transport.lock().unwrap().is_none()
2678 && std::time::Instant::now() < deadline
2679 {
2680 std::thread::sleep(std::time::Duration::from_millis(10));
2681 }
2682 assert!(
2683 session.transport.transport.lock().unwrap().is_some(),
2684 "lazy Node transport did not start"
2685 );
2686
2687 session.shutdown();
2688 session.shutdown();
2689 let result = receiver
2690 .recv_timeout(std::time::Duration::from_secs(2))
2691 .expect("shutdown should wake the detached command");
2692 assert!(result.is_err(), "stopped command unexpectedly succeeded");
2693 worker.join().unwrap();
2694 assert!(
2695 session.transport.ensure().is_err(),
2696 "shutdown must prevent lazy Node restart"
2697 );
2698 }
2699
2700 #[test]
2701 fn shutting_down_during_lazy_initialization_does_not_wait_for_slot_lock() {
2702 let temp = tempfile::tempdir().unwrap();
2703 let path = temp.path().join("shutdown-init.js");
2704 std::fs::write(
2705 &path,
2706 r#"export default (pi) => {
2707 pi.on('before_agent_start', async (_event, ctx) => {
2708 if (ctx.hasUI) {
2709 await new Promise(resolve => setTimeout(resolve, 5000));
2710 }
2711 });
2712 pi.registerCommand('wait', async () => ({ text: 'late' }));
2713 };"#,
2714 )
2715 .unwrap();
2716 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2717 session
2720 .set_runtime_context(serde_json::json!({
2721 "mode": "tui",
2722 "hasUI": true,
2723 "capabilities": ["ui.custom"],
2724 }))
2725 .unwrap();
2726 let worker_session = session.clone();
2727 let (command_sender, command_receiver) = std::sync::mpsc::channel();
2728 let command_worker = std::thread::spawn(move || {
2729 command_sender
2730 .send(worker_session.invoke_command("wait", ""))
2731 .unwrap();
2732 });
2733
2734 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
2735 while session.transport.starting.lock().unwrap().is_none()
2736 && std::time::Instant::now() < deadline
2737 {
2738 std::thread::sleep(std::time::Duration::from_millis(10));
2739 }
2740 assert!(
2741 session.transport.starting.lock().unwrap().is_some(),
2742 "lazy startup did not publish its in-flight transport"
2743 );
2744
2745 let shutdown_session = session.clone();
2746 let (shutdown_sender, shutdown_receiver) = std::sync::mpsc::channel();
2747 let shutdown_worker = std::thread::spawn(move || {
2748 shutdown_session.shutdown();
2749 shutdown_sender.send(()).unwrap();
2750 });
2751 shutdown_receiver
2752 .recv_timeout(std::time::Duration::from_secs(2))
2753 .expect("shutdown should not wait for a blocked initialization request");
2754 let result = command_receiver
2755 .recv_timeout(std::time::Duration::from_secs(2))
2756 .expect("shutdown should wake lazy initialization");
2757 assert!(
2758 result.is_err(),
2759 "stopped initialization unexpectedly succeeded"
2760 );
2761 command_worker.join().unwrap();
2762 shutdown_worker.join().unwrap();
2763 }
2764
2765 #[test]
2766 fn shutting_down_during_node_initialization_kills_host_before_init_response() {
2767 let temp = tempfile::tempdir().unwrap();
2768 let path = temp.path().join("shutdown-before-init.js");
2769 std::fs::write(
2770 &path,
2771 r#"export default (pi) => {
2772 pi.on('before_agent_start', async (_event, ctx) => {
2773 // Discovery runs in the one-shot host. Only the persistent
2774 // host deliberately blocks before publishing id:0.
2775 if (ctx.hasUI && process.env.RPI_JS_EXTENSION_ONESHOT !== '1') {
2776 await new Promise(resolve => setTimeout(resolve, 30000));
2777 }
2778 });
2779 pi.registerCommand('wait', async () => ({ text: 'late' }));
2780 };"#,
2781 )
2782 .unwrap();
2783
2784 let session = JsExtensionSession::load_with_context(
2785 &[path],
2786 false,
2787 serde_json::json!({"mode":"tui", "hasUI":true}),
2788 )
2789 .unwrap()
2790 .unwrap();
2791 let worker_session = session.clone();
2792 let (command_sender, command_receiver) = std::sync::mpsc::channel();
2793 let command_worker = std::thread::spawn(move || {
2794 command_sender
2795 .send(worker_session.invoke_command("wait", ""))
2796 .unwrap();
2797 });
2798
2799 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
2800 while session.transport.starting.lock().unwrap().is_none()
2801 && std::time::Instant::now() < deadline
2802 {
2803 std::thread::sleep(std::time::Duration::from_millis(10));
2804 }
2805 assert!(
2806 session.transport.starting.lock().unwrap().is_some(),
2807 "lazy startup did not publish its transport before init"
2808 );
2809
2810 let started = std::time::Instant::now();
2811 session.shutdown();
2812 let result = command_receiver
2813 .recv_timeout(std::time::Duration::from_secs(2))
2814 .expect("shutdown should wake a command blocked in Node init");
2815 assert!(
2816 result.is_err(),
2817 "stopped initialization unexpectedly succeeded"
2818 );
2819 assert!(
2820 started.elapsed() < std::time::Duration::from_secs(2),
2821 "shutdown waited for the blocked init hook"
2822 );
2823 command_worker.join().unwrap();
2824 }
2825
2826 #[test]
2827 fn cancelling_prompt_preparation_kills_stuck_hook_and_allows_restart() {
2828 let temp = tempfile::tempdir().unwrap();
2829 let marker = temp.path().join("blocked-once");
2830 let marker_literal = serde_json::to_string(&marker.to_string_lossy()).unwrap();
2831 let path = temp.path().join("cancel-prompt-preparation.js");
2832 std::fs::write(
2833 &path,
2834 format!(
2835 r#"import fs from 'node:fs';
2836 export default (pi) => {{
2837 pi.registerCommand('ping', async () => ({{ text: 'pong' }}));
2838 pi.on('before_agent_start', async () => {{
2839 if (process.env.RPI_JS_EXTENSION_ONESHOT !== '1'
2840 && !fs.existsSync({marker})) {{
2841 fs.writeFileSync({marker}, 'blocked');
2842 await new Promise(() => {{}});
2843 }}
2844 }});
2845 }};"#,
2846 marker = marker_literal,
2847 ),
2848 )
2849 .unwrap();
2850
2851 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2852 let cancellation = CancellationToken::new();
2853 let worker_session = session.clone();
2854 let worker_cancellation = cancellation.clone();
2855 let (sender, receiver) = std::sync::mpsc::channel();
2856 let worker = std::thread::spawn(move || {
2857 sender
2858 .send(worker_session.prepare_for_prompt_with_cancellation(&worker_cancellation))
2859 .unwrap();
2860 });
2861
2862 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
2863 while !marker.is_file() && std::time::Instant::now() < deadline {
2864 std::thread::sleep(std::time::Duration::from_millis(5));
2865 }
2866 assert!(marker.is_file(), "persistent lifecycle hook did not block");
2867
2868 cancellation.cancel();
2869 session.cancel_prompt_preparation();
2870 let result = receiver
2871 .recv_timeout(std::time::Duration::from_secs(2))
2872 .expect("cancellation should wake a stuck prompt preparation");
2873 assert!(
2874 result.is_err(),
2875 "cancelled preparation unexpectedly succeeded"
2876 );
2877 worker.join().unwrap();
2878 assert!(session.transport.transport.lock().unwrap().is_none());
2879 assert!(session.transport.starting.lock().unwrap().is_none());
2880
2881 session.prepare_for_prompt().unwrap();
2884 let value = session.invoke_command("ping", "").unwrap();
2885 assert_eq!(value["result"]["text"], "pong");
2886 }
2887
2888 #[test]
2889 fn node_transport_cancellation_reaches_tool_abort_signal() {
2890 let temp = tempfile::tempdir().unwrap();
2891 let path = temp.path().join("cancel.js");
2892 std::fs::write(
2893 &path,
2894 r#"export default (pi) => pi.registerTool({
2895 name: 'wait',
2896 description: 'wait for cancellation',
2897 parameters: { type: 'object', properties: {} },
2898 execute: (_id, _args, signal) => new Promise(resolve => {
2899 signal.addEventListener('abort', () => resolve({
2900 content: [{ type: 'text', text: 'cancelled' }],
2901 details: {}
2902 }), { once: true });
2903 })
2904 });"#,
2905 )
2906 .unwrap();
2907 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2908 let pending = session
2909 .transport
2910 .begin_request(
2911 "invoke_tool",
2912 serde_json::json!({"tool": "wait", "toolCallId": "test", "args": {}}),
2913 )
2914 .unwrap();
2915 let request_id = pending.id();
2916 std::thread::sleep(std::time::Duration::from_millis(30));
2917 session.transport.cancel(request_id).unwrap();
2918 let result = pending.wait().unwrap();
2919 assert_eq!(result["content"][0]["text"], "cancelled");
2920 }
2921
2922 #[test]
2923 fn unsupported_ui_capabilities_fail_with_explicit_error() {
2924 let temp = tempfile::tempdir().unwrap();
2925 let path = temp.path().join("ui.js");
2926 std::fs::write(
2927 &path,
2928 "export default (pi) => pi.registerCommand('ui', async (_args, ctx) => { await ctx.ui.custom(() => null); });",
2929 )
2930 .unwrap();
2931 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2932 let error = session.invoke_command("ui", "").unwrap_err();
2933 assert!(error.contains("unsupported capability: ui.custom"));
2934 }
2935
2936 #[test]
2937 fn custom_ui_roundtrips_open_render_and_done() {
2938 let temp = tempfile::tempdir().unwrap();
2939 let path = temp.path().join("custom.js");
2940 std::fs::write(
2941 &path,
2942 r#"export default (pi) => pi.registerCommand('custom', async (_args, ctx) => {
2943 return await ctx.ui.custom((parent, theme, keybindings, done) => {
2944 parent.terminal.write(theme.fg('muted', 'frame'));
2945 done({ ok: true });
2946 return { render() { return ['frame']; }, handleInput() { return true; } };
2947 }, { overlay: true });
2948 });"#,
2949 )
2950 .unwrap();
2951 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2952 let actions = Arc::new(Mutex::new(Vec::<String>::new()));
2953 let observed = actions.clone();
2954 session
2955 .set_runtime_handler(Arc::new(move |action, args| {
2956 observed.lock().unwrap().push(action.to_string());
2957 match action {
2958 "ui.custom.open" | "ui.custom.close" => Ok(serde_json::json!(true)),
2959 "ui.custom.write" => {
2960 let data = args["data"].as_str().unwrap_or_default();
2961 assert!(data.ends_with("frame\r\n") || data.starts_with("frame"));
2962 Ok(serde_json::json!(true))
2963 }
2964 _ => Err(format!("unsupported capability: {action}")),
2965 }
2966 }))
2967 .unwrap();
2968 let value = session.invoke_command("custom", "").unwrap();
2969 assert_eq!(value["result"]["ok"], true);
2970 let actions = actions.lock().unwrap();
2971 assert!(actions.iter().any(|action| action == "ui.custom.open"));
2972 assert!(actions.iter().any(|action| action == "ui.custom.write"));
2973 assert!(actions.iter().any(|action| action == "ui.custom.close"));
2974 }
2975
2976 #[test]
2977 fn custom_ui_forwards_overlay_options_handle_and_terminal_size() {
2978 let temp = tempfile::tempdir().unwrap();
2979 let path = temp.path().join("custom-overlay.js");
2980 std::fs::write(
2981 &path,
2982 r#"export default (pi) => pi.registerCommand('custom', async (_args, ctx) => {
2983 let finish;
2984 const result = ctx.ui.custom((parent, _theme, _keys, done) => {
2985 if (parent.terminal.columns !== 90 || parent.terminal.rows !== 20) throw new Error('wrong terminal size');
2986 finish = done;
2987 return { render() { return ['overlay-frame']; }, invalidate() {}, dispose() {} };
2988 }, {
2989 overlay: true,
2990 overlayOptions: { anchor: 'bottom-center', width: '100%', maxHeight: '100%', margin: { left: 0, right: 0, bottom: 0 } },
2991 onHandle(handle) {
2992 if (!handle.isFocused() || handle.isHidden()) throw new Error('invalid overlay handle');
2993 handle.setHidden(true);
2994 if (!handle.isHidden()) throw new Error('setHidden(true) was ignored');
2995 handle.setHidden(false);
2996 if (handle.isHidden()) throw new Error('setHidden(false) was ignored');
2997 finish({ ok: true });
2998 }
2999 });
3000 return await result;
3001 });"#,
3002 )
3003 .unwrap();
3004 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
3005 let actions = Arc::new(Mutex::new(Vec::<(String, serde_json::Value)>::new()));
3006 let observed = actions.clone();
3007 session
3008 .set_runtime_handler(Arc::new(move |action, args| {
3009 observed
3010 .lock()
3011 .unwrap()
3012 .push((action.to_string(), args.clone()));
3013 match action {
3014 "ui.custom.open" => Ok(serde_json::json!({ "columns": 90, "rows": 20 })),
3015 "ui.custom.handle" | "ui.custom.close" | "ui.custom.invalidate" => {
3016 Ok(serde_json::json!(true))
3017 }
3018 "ui.custom.write" => Ok(serde_json::json!(true)),
3019 _ => Err(format!("unsupported capability: {action}")),
3020 }
3021 }))
3022 .unwrap();
3023 let value = session.invoke_command("custom", "").unwrap();
3024 assert_eq!(value["result"]["ok"], true);
3025 let actions = actions.lock().unwrap();
3026 let open = actions
3027 .iter()
3028 .find(|(action, _)| action == "ui.custom.open")
3029 .expect("custom open action");
3030 assert_eq!(open.1["options"]["overlay"], true);
3031 assert_eq!(
3032 open.1["options"]["overlayOptions"]["anchor"],
3033 "bottom-center"
3034 );
3035 assert!(actions
3036 .iter()
3037 .any(|(action, _)| action == "ui.custom.handle"));
3038 assert!(actions
3039 .iter()
3040 .any(|(action, args)| { action == "ui.custom.handle" && args["hidden"] == true }));
3041 assert!(actions
3042 .iter()
3043 .any(|(action, args)| { action == "ui.custom.handle" && args["hidden"] == false }));
3044 }
3045
3046 #[test]
3047 fn custom_ui_terminal_input_listener_can_consume_before_component() {
3048 let temp = tempfile::tempdir().unwrap();
3049 let path = temp.path().join("custom-input.js");
3050 std::fs::write(
3051 &path,
3052 r#"export default (pi) => pi.registerCommand('custom', async (_args, ctx) => {
3053 let finish;
3054 ctx.ui.onTerminalInput((data) => {
3055 if (data !== 'x') return;
3056 finish({ consumed: true });
3057 return { consume: true };
3058 });
3059 return await ctx.ui.custom((_parent, _theme, _keys, done) => {
3060 finish = done;
3061 return { handleInput() { throw new Error('component received consumed input'); } };
3062 });
3063 });"#,
3064 )
3065 .unwrap();
3066 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
3067 let tui = Arc::new(rpi_tui::TuiAltScreen::new(
3068 Box::new(rpi_tui::ProcessTerminal::new()),
3069 true,
3070 None,
3071 ));
3072 session.install_ui_runtime(tui).unwrap();
3073
3074 let worker_session = session.clone();
3075 let (sender, receiver) = std::sync::mpsc::channel();
3076 std::thread::spawn(move || {
3077 sender
3078 .send(worker_session.invoke_command("custom", ""))
3079 .unwrap();
3080 });
3081 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
3082 while !session.custom_active() && std::time::Instant::now() < deadline {
3083 std::thread::sleep(std::time::Duration::from_millis(10));
3084 }
3085 assert!(session.custom_active(), "custom UI did not open");
3086 session.send_custom_input("x").unwrap();
3087 let value = receiver
3088 .recv_timeout(std::time::Duration::from_secs(5))
3089 .unwrap()
3090 .unwrap();
3091 assert_eq!(value["result"]["consumed"], true);
3092 }
3093
3094 #[test]
3095 fn custom_ui_terminal_input_listener_can_swallow_with_empty_data() {
3096 let temp = tempfile::tempdir().unwrap();
3097 let path = temp.path().join("custom-empty-input.js");
3098 std::fs::write(
3099 &path,
3100 r#"export default (pi) => pi.registerCommand('custom', async (_args, ctx) => {
3101 let finish;
3102 let componentCalls = 0;
3103 ctx.ui.onTerminalInput((data) => {
3104 if (data !== 'empty') return;
3105 // Let the current input dispatch finish before resolving
3106 // the command. Without the host's empty-data short-circuit
3107 // the component is called before this timer and the count
3108 // becomes 1.
3109 setTimeout(() => finish({ componentCalls }), 0);
3110 return { data: '' };
3111 });
3112 return await ctx.ui.custom((_parent, _theme, _keys, done) => {
3113 finish = done;
3114 return { handleInput() { componentCalls += 1; } };
3115 });
3116 });"#,
3117 )
3118 .unwrap();
3119 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
3120 let tui = Arc::new(rpi_tui::TuiAltScreen::new(
3121 Box::new(rpi_tui::ProcessTerminal::new()),
3122 true,
3123 None,
3124 ));
3125 session.install_ui_runtime(tui).unwrap();
3126
3127 let worker_session = session.clone();
3128 let (sender, receiver) = std::sync::mpsc::channel();
3129 std::thread::spawn(move || {
3130 sender
3131 .send(worker_session.invoke_command("custom", ""))
3132 .unwrap();
3133 });
3134 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
3135 while !session.custom_active() && std::time::Instant::now() < deadline {
3136 std::thread::sleep(std::time::Duration::from_millis(10));
3137 }
3138 assert!(session.custom_active(), "custom UI did not open");
3139
3140 session.send_custom_input("empty").unwrap();
3141 let value = receiver
3142 .recv_timeout(std::time::Duration::from_secs(2))
3143 .unwrap()
3144 .unwrap();
3145 assert_eq!(value["result"]["componentCalls"], 0);
3146 }
3147
3148 #[test]
3149 fn hidden_custom_ui_releases_unconsumed_input_but_keeps_listener() {
3150 let temp = tempfile::tempdir().unwrap();
3151 let path = temp.path().join("custom-hidden-input.js");
3152 std::fs::write(
3153 &path,
3154 r#"export default (pi) => pi.registerCommand('custom', async (_args, ctx) => {
3155 let finish;
3156 let reopen;
3157 let componentCalls = 0;
3158 ctx.ui.onTerminalInput((data) => {
3159 if (data === 'reopen') {
3160 reopen?.setHidden(false);
3161 return { consume: true };
3162 }
3163 if (data !== 'finish') return;
3164 finish({ componentCalls });
3165 return { consume: true };
3166 });
3167 return await ctx.ui.custom((_parent, _theme, _keys, done) => {
3168 finish = done;
3169 return { handleInput() { componentCalls += 1; } };
3170 }, { onHandle(handle) { reopen = handle; handle.setHidden(true); } });
3171 });"#,
3172 )
3173 .unwrap();
3174 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
3175 let tui = Arc::new(rpi_tui::TuiAltScreen::new(
3176 Box::new(rpi_tui::ProcessTerminal::new()),
3177 true,
3178 None,
3179 ));
3180 session.install_ui_runtime(tui).unwrap();
3181
3182 let worker_session = session.clone();
3183 let (sender, receiver) = std::sync::mpsc::channel();
3184 std::thread::spawn(move || {
3185 sender
3186 .send(worker_session.invoke_command("custom", ""))
3187 .unwrap();
3188 });
3189 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
3190 while (session.custom_accepts_input() || !session.custom_active())
3191 && std::time::Instant::now() < deadline
3192 {
3193 std::thread::sleep(std::time::Duration::from_millis(10));
3194 }
3195 assert!(session.custom_active(), "custom UI did not open");
3196 assert!(
3197 !session.custom_accepts_input(),
3198 "hidden UI kept keyboard focus"
3199 );
3200
3201 assert!(!session.send_custom_input_with_consumed("ordinary").unwrap());
3204 assert!(session.send_custom_input_with_consumed("reopen").unwrap());
3205 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
3206 while !session.custom_accepts_input() && std::time::Instant::now() < deadline {
3207 std::thread::sleep(std::time::Duration::from_millis(10));
3208 }
3209 assert!(
3210 session.custom_accepts_input(),
3211 "reopen shortcut did not restore focus"
3212 );
3213 assert!(session.send_custom_input_with_consumed("finish").unwrap());
3216 let value = receiver
3217 .recv_timeout(std::time::Duration::from_secs(2))
3218 .unwrap()
3219 .unwrap();
3220 assert_eq!(value["result"]["componentCalls"], 0);
3221 }
3222
3223 #[test]
3224 fn custom_ui_closes_when_command_is_cancelled() {
3225 let temp = tempfile::tempdir().unwrap();
3226 let path = temp.path().join("custom-cancel.js");
3227 std::fs::write(
3228 &path,
3229 r#"export default (pi) => pi.registerCommand('custom', async (_args, ctx) => {
3230 return await ctx.ui.custom((_parent, _theme, _keys, _done) => ({
3231 handleInput() {},
3232 dispose() {},
3233 }));
3234 });"#,
3235 )
3236 .unwrap();
3237 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
3238 let tui = Arc::new(rpi_tui::TuiAltScreen::new(
3239 Box::new(rpi_tui::ProcessTerminal::new()),
3240 true,
3241 None,
3242 ));
3243 session.install_ui_runtime(tui).unwrap();
3244
3245 let pending = session
3246 .transport
3247 .begin_request(
3248 "invoke_command",
3249 serde_json::json!({"command":"custom", "args":"", "context":{}}),
3250 )
3251 .unwrap();
3252 let request_id = pending.id();
3253 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
3254 while !session.custom_active() && std::time::Instant::now() < deadline {
3255 std::thread::sleep(std::time::Duration::from_millis(10));
3256 }
3257 assert!(session.custom_active(), "custom UI did not open");
3258
3259 session.transport.cancel(request_id).unwrap();
3260 let value = pending
3261 .wait()
3262 .expect("cancelled custom command should resolve");
3263 assert!(value.get("result").is_some());
3264 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
3265 while session.custom_active() && std::time::Instant::now() < deadline {
3266 std::thread::sleep(std::time::Duration::from_millis(10));
3267 }
3268 assert!(
3269 !session.custom_active(),
3270 "cancelled custom UI remained active"
3271 );
3272 }
3273
3274 #[test]
3275 fn runtime_handler_added_during_startup_reaches_starting_host() {
3276 let temp = tempfile::tempdir().unwrap();
3277 let ready = temp.path().join("persistent-factory-ready");
3278 let release = temp.path().join("release-persistent-factory");
3279 let path = temp.path().join("startup-handler-race.js");
3280 let ready_literal = serde_json::to_string(&ready.to_string_lossy()).unwrap();
3281 let release_literal = serde_json::to_string(&release.to_string_lossy()).unwrap();
3282 std::fs::write(
3283 &path,
3284 format!(
3285 r#"import fs from 'node:fs';
3286 const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3287 export default async (pi) => {{
3288 if (process.env.RPI_JS_EXTENSION_ONESHOT === '1') {{
3289 pi.registerCommand('late', async () => ({{ text: 'discovery' }}));
3290 return;
3291 }}
3292 fs.writeFileSync({ready}, 'ready');
3293 while (!fs.existsSync({release})) await sleep(5);
3294 const value = await pi.runtimeRequest('late.echo', {{ value: 'handled' }});
3295 pi.registerCommand('late', async () => ({{ text: String(value) }}));
3296 }};"#,
3297 ready = ready_literal,
3298 release = release_literal,
3299 ),
3300 )
3301 .unwrap();
3302
3303 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
3304 let worker_session = session.clone();
3305 let (sender, receiver) = std::sync::mpsc::channel();
3306 std::thread::spawn(move || {
3307 sender
3308 .send(worker_session.invoke_command("late", ""))
3309 .unwrap();
3310 });
3311
3312 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
3313 while !ready.is_file() && std::time::Instant::now() < deadline {
3314 std::thread::sleep(std::time::Duration::from_millis(5));
3315 }
3316 assert!(ready.is_file(), "persistent factory did not reach startup");
3317 while session.transport.starting.lock().unwrap().is_none()
3318 && std::time::Instant::now() < deadline
3319 {
3320 std::thread::sleep(std::time::Duration::from_millis(5));
3321 }
3322 assert!(
3323 session.transport.starting.lock().unwrap().is_some(),
3324 "startup transport was not published"
3325 );
3326
3327 session
3328 .set_runtime_handler(Arc::new(|action, args| {
3329 if action == "late.echo" {
3330 Ok(args.get("value").cloned().unwrap_or_default())
3331 } else {
3332 Err(format!("unsupported capability: {action}"))
3333 }
3334 }))
3335 .unwrap();
3336 std::fs::write(&release, "release").unwrap();
3337
3338 let result = receiver
3339 .recv_timeout(std::time::Duration::from_secs(2))
3340 .expect("startup command did not finish");
3341 assert_eq!(result.unwrap()["result"]["text"], "handled");
3342 }
3343}