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 record_active_tools_for_revision(&self, revision: u64, value: Option<&serde_json::Value>) {
829 if self.context_revision.load(Ordering::Acquire) != revision {
830 return;
831 }
832 self.record_active_tools(value);
833 }
834
835 fn restore_pending_context(&self, restored: Option<serde_json::Value>) {
842 let Some(mut restored) = restored else {
843 return;
844 };
845 let Ok(_context_guard) = self.context_lock.lock() else {
846 return;
847 };
848 let Ok(mut pending) = self.pending_context.lock() else {
849 return;
850 };
851 if let Some(current) = pending.take() {
852 merge_runtime_context(&mut restored, ¤t);
853 }
854 *pending = Some(restored);
855 }
856
857 fn ensure(&self) -> Result<NodeTransport, String> {
858 self.ensure_with_cancellation(None)
859 }
860
861 fn ensure_with_cancellation(
862 &self,
863 cancellation: Option<&CancellationToken>,
864 ) -> Result<NodeTransport, String> {
865 let cancelled = || cancellation.is_some_and(CancellationToken::is_cancelled);
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
876 let _start_guard = self
880 .start_lock
881 .lock()
882 .map_err(|_| "Node startup lock poisoned")?;
883 if cancelled() {
884 return Err(NODE_PREPARATION_CANCELLED.into());
885 }
886 if self.shutdown.load(Ordering::Acquire) {
887 return Err(NODE_HOST_STOPPED.into());
888 }
889 if let Some(transport) = self.live_transport()? {
890 return Ok(transport);
891 }
892 let initial_handlers = self
893 .handlers
894 .lock()
895 .map_err(|_| "Node runtime handler lock poisoned")?
896 .clone();
897 let (initial_context, initial_pending, initial_revision) = {
903 let _context_guard = self
904 .context_lock
905 .lock()
906 .map_err(|_| "Node runtime context lock poisoned")?;
907 let mut context = (*self.context).clone();
908 let mut pending = self
909 .pending_context
910 .lock()
911 .map_err(|_| "Node runtime context lock poisoned")?;
912 let initial_pending = pending.take();
913 let initial_revision = self.context_revision.load(Ordering::Acquire);
914 if let Some(next) = initial_pending.as_ref() {
915 merge_runtime_context(&mut context, next);
916 }
917 (context, initial_pending, initial_revision)
918 };
919
920 let startup =
925 match start_node_transport(&self.paths, &initial_context, false, &initial_handlers) {
926 Ok(startup) => startup,
927 Err(error) => {
928 self.restore_pending_context(initial_pending.clone());
929 return Err(error);
930 }
931 };
932 let transport = startup.transport();
933
934 if cancelled() {
937 transport.shutdown();
938 self.restore_pending_context(initial_pending.clone());
939 return Err(NODE_PREPARATION_CANCELLED.into());
940 }
941
942 {
943 let mut starting = self
944 .starting
945 .lock()
946 .map_err(|_| "Node startup state lock poisoned")?;
947 if self.shutdown.load(Ordering::Acquire) || cancelled() {
948 drop(starting);
949 transport.shutdown();
950 self.restore_pending_context(initial_pending.clone());
951 return Err(if cancelled() {
952 NODE_PREPARATION_CANCELLED.into()
953 } else {
954 NODE_HOST_STOPPED.into()
955 });
956 }
957 *starting = Some(transport.clone());
958 if cancelled() {
962 let stopped = starting.take();
963 drop(starting);
964 if let Some(stopped) = stopped {
965 stopped.shutdown();
966 }
967 self.restore_pending_context(initial_pending.clone());
968 return Err(NODE_PREPARATION_CANCELLED.into());
969 }
970 }
971
972 let init = match startup.wait() {
973 Ok((_, init)) => init,
974 Err(error) => {
975 if let Ok(mut starting) = self.starting.lock() {
976 starting.take();
977 }
978 self.restore_pending_context(initial_pending.clone());
979 return Err(error);
980 }
981 };
982
983 let startup_result = (|| -> Result<NodeTransport, String> {
984 if cancelled() {
985 return Err(NODE_PREPARATION_CANCELLED.into());
986 }
987 if !init
988 .get("ok")
989 .and_then(serde_json::Value::as_bool)
990 .unwrap_or(false)
991 {
992 return Err(init
993 .get("error")
994 .and_then(serde_json::Value::as_str)
995 .unwrap_or("Node extension load failed")
996 .to_string());
997 }
998 self.record_active_tools(
999 init.get("result")
1000 .and_then(|value| value.get("activeTools")),
1001 );
1002 let handlers = self
1003 .handlers
1004 .lock()
1005 .map_err(|_| "Node runtime handler lock poisoned")?
1006 .clone();
1007 if let Some(first) = handlers.first() {
1008 transport.replace_runtime_handlers(first.clone())?;
1009 for handler in handlers.iter().skip(1).cloned() {
1010 transport.add_runtime_handler(handler)?;
1011 }
1012 }
1013 let mut startup_context = initial_pending
1017 .clone()
1018 .unwrap_or_else(|| serde_json::json!({}));
1019 let mut startup_pending = Some((
1032 initial_pending
1033 .clone()
1034 .unwrap_or_else(|| serde_json::json!({})),
1035 initial_revision,
1036 ));
1037 loop {
1041 if cancelled() {
1042 return Err(NODE_PREPARATION_CANCELLED.into());
1043 }
1044 let next = if let Some(next) = startup_pending.take() {
1045 Some(next)
1046 } else {
1047 let _context_guard = self
1048 .context_lock
1049 .lock()
1050 .map_err(|_| "Node runtime context lock poisoned")?;
1051 let mut pending = self
1052 .pending_context
1053 .lock()
1054 .map_err(|_| "Node runtime context lock poisoned")?;
1055 if let Some(context) = pending.take() {
1056 let revision = self.context_revision.load(Ordering::Acquire);
1057 Some((context, revision))
1058 } else {
1059 *pending = Some(startup_context.clone());
1064 let mut slot = self
1065 .transport
1066 .lock()
1067 .map_err(|_| "Node transport lock poisoned")?;
1068 if self.shutdown.load(Ordering::Acquire) || cancelled() {
1069 return Err(if cancelled() {
1070 NODE_PREPARATION_CANCELLED.into()
1071 } else {
1072 NODE_HOST_STOPPED.into()
1073 });
1074 }
1075 if let Some(existing) = slot.as_ref() {
1076 return Ok(existing.clone());
1077 }
1078 *slot = Some(transport.clone());
1079 if self.shutdown.load(Ordering::Acquire) || cancelled() {
1084 let stopped = slot.take();
1085 drop(slot);
1086 if let Some(stopped) = stopped {
1087 stopped.shutdown();
1088 }
1089 return Err(if cancelled() {
1090 NODE_PREPARATION_CANCELLED.into()
1091 } else {
1092 NODE_HOST_STOPPED.into()
1093 });
1094 }
1095 return Ok(transport.clone());
1096 }
1097 };
1098 let Some((context, revision)) = next else {
1099 continue;
1100 };
1101 if cancelled() {
1102 return Err(NODE_PREPARATION_CANCELLED.into());
1103 }
1104 merge_runtime_context(&mut startup_context, &context);
1105 let result = match transport.request(
1106 "set_runtime_context",
1107 serde_json::json!({"context": context, "revision": revision}),
1108 ) {
1109 Ok(result) => result,
1110 Err(error) => {
1111 self.restore_pending_context(Some(startup_context));
1116 return Err(error);
1117 }
1118 };
1119 self.record_active_tools_for_revision(revision, result.get("activeTools"));
1120 }
1121 })();
1122
1123 if let Ok(mut starting) = self.starting.lock() {
1126 starting.take();
1127 }
1128 match startup_result {
1129 Ok(transport) => Ok(transport),
1130 Err(error) => {
1131 transport.shutdown();
1132 self.restore_pending_context(initial_pending);
1133 Err(error)
1134 }
1135 }
1136 }
1137
1138 fn request(
1139 &self,
1140 method: &str,
1141 payload: serde_json::Value,
1142 ) -> Result<serde_json::Value, String> {
1143 self.ensure()?.request(method, payload)
1144 }
1145
1146 fn begin_request(
1147 &self,
1148 method: &str,
1149 payload: serde_json::Value,
1150 ) -> Result<crate::node_transport::PendingRequest, String> {
1151 self.ensure()?.begin_request(method, payload)
1152 }
1153
1154 fn send_event(&self, event: serde_json::Value) -> Result<(), String> {
1155 self.ensure()?.send_event(event)
1156 }
1157
1158 fn cancel(&self, id: u64) -> Result<(), String> {
1159 self.ensure()?.cancel(id)
1160 }
1161
1162 fn register_tool_update_handler(
1163 &self,
1164 tool_call_id: impl Into<String>,
1165 handler: crate::node_transport::ToolUpdateHandler,
1166 ) -> Result<(), String> {
1167 self.ensure()?
1168 .register_tool_update_handler(tool_call_id, handler)
1169 }
1170
1171 fn unregister_tool_update_handler(&self, tool_call_id: &str) {
1172 if let Ok(slot) = self.transport.lock() {
1173 if let Some(transport) = slot.as_ref() {
1174 transport.unregister_tool_update_handler(tool_call_id);
1175 }
1176 }
1177 }
1178
1179 fn handler_update_transports(&self) -> Result<Vec<NodeTransport>, String> {
1184 let active = self.live_transport()?;
1185 let starting = self
1186 .starting
1187 .lock()
1188 .map_err(|_| "Node startup state lock poisoned")?
1189 .as_ref()
1190 .filter(|transport| !transport.is_shutdown())
1191 .cloned();
1192 let mut transports = Vec::with_capacity(2);
1193 if let Some(active) = active {
1194 transports.push(active);
1195 }
1196 if let Some(starting) = starting {
1197 if !transports
1198 .iter()
1199 .any(|transport| transport.same_instance(&starting))
1200 {
1201 transports.push(starting);
1202 }
1203 }
1204 Ok(transports)
1205 }
1206
1207 fn replace_runtime_handlers(&self, handler: RuntimeHandler) -> Result<(), String> {
1208 *self
1209 .handlers
1210 .lock()
1211 .map_err(|_| "Node runtime handler lock poisoned")? = vec![handler.clone()];
1212 for transport in self.handler_update_transports()? {
1213 transport.replace_runtime_handlers(handler.clone())?;
1214 }
1215 Ok(())
1216 }
1217
1218 fn add_runtime_handler(&self, handler: RuntimeHandler) -> Result<(), String> {
1219 self.handlers
1220 .lock()
1221 .map_err(|_| "Node runtime handler lock poisoned")?
1222 .push(handler.clone());
1223 for transport in self.handler_update_transports()? {
1224 transport.add_runtime_handler(handler.clone())?;
1225 }
1226 Ok(())
1227 }
1228
1229 fn set_runtime_context(&self, context: serde_json::Value) -> Result<(), String> {
1230 if self.shutdown.load(Ordering::Acquire) {
1231 return Err(NODE_HOST_STOPPED.into());
1232 }
1233 let (merged, revision, transport) = {
1237 let _context_guard = self
1238 .context_lock
1239 .lock()
1240 .map_err(|_| "Node runtime context lock poisoned")?;
1241 let mut pending = self
1242 .pending_context
1243 .lock()
1244 .map_err(|_| "Node runtime context lock poisoned")?;
1245 let mut merged = pending.take().unwrap_or_else(|| serde_json::json!({}));
1246 merge_runtime_context(&mut merged, &context);
1247 *pending = Some(merged.clone());
1248 let revision = self.context_revision.fetch_add(1, Ordering::AcqRel) + 1;
1249 let transport = self.live_transport()?;
1254 (merged, revision, transport)
1255 };
1256 if let Some(transport) = transport {
1257 let result = transport.request(
1258 "set_runtime_context",
1259 serde_json::json!({"context": merged, "revision": revision}),
1260 )?;
1261 self.record_active_tools_for_revision(revision, result.get("activeTools"));
1262 }
1263 Ok(())
1264 }
1265}
1266
1267fn merge_runtime_context(base: &mut serde_json::Value, next: &serde_json::Value) {
1273 let (Some(base_object), Some(next_object)) = (base.as_object_mut(), next.as_object()) else {
1274 *base = next.clone();
1275 return;
1276 };
1277 for (key, value) in next_object {
1278 if key == "capabilities" {
1279 let Some(next_values) = value.as_array() else {
1280 base_object.insert(key.clone(), value.clone());
1281 continue;
1282 };
1283 let entry = base_object
1284 .entry(key.clone())
1285 .or_insert_with(|| serde_json::Value::Array(Vec::new()));
1286 let Some(base_values) = entry.as_array_mut() else {
1287 *entry = serde_json::Value::Array(Vec::new());
1288 let Some(base_values) = entry.as_array_mut() else {
1289 continue;
1290 };
1291 for item in next_values {
1292 if !base_values.contains(item) {
1293 base_values.push(item.clone());
1294 }
1295 }
1296 continue;
1297 };
1298 for item in next_values {
1299 if !base_values.contains(item) {
1300 base_values.push(item.clone());
1301 }
1302 }
1303 } else {
1304 base_object.insert(key.clone(), value.clone());
1305 }
1306 }
1307}
1308
1309fn normalize_provider_context(mut value: serde_json::Value) -> Result<Context, String> {
1314 let Some(messages) = value
1315 .get_mut("messages")
1316 .and_then(serde_json::Value::as_array_mut)
1317 else {
1318 return serde_json::from_value(value).map_err(|error| error.to_string());
1319 };
1320 for message in messages {
1321 let Some(object) = message.as_object_mut() else {
1322 continue;
1323 };
1324 if object.contains_key("role") {
1325 continue;
1326 }
1327 let inferred = if object.contains_key("toolCallId")
1328 || object.contains_key("toolName")
1329 || object.contains_key("isError")
1330 {
1331 Some("toolResult")
1332 } else if object.contains_key("stopReason")
1333 && object.contains_key("content")
1334 && (object.contains_key("provider") || object.contains_key("model"))
1335 {
1336 Some("assistant")
1337 } else {
1338 None
1339 };
1340 if let Some(role) = inferred {
1341 object.insert(
1342 "role".to_string(),
1343 serde_json::Value::String(role.to_string()),
1344 );
1345 }
1346 }
1347 serde_json::from_value(value).map_err(|error| error.to_string())
1348}
1349
1350fn parse_simple_stream_options(
1351 value: Option<&serde_json::Value>,
1352) -> Result<SimpleStreamOptions, String> {
1353 let value = value.cloned().unwrap_or_default();
1354 let mut options = SimpleStreamOptions::default();
1355 options.api_key = value
1356 .get("apiKey")
1357 .and_then(|v| v.as_str())
1358 .map(String::from);
1359 options.headers = value
1360 .get("headers")
1361 .cloned()
1362 .map(serde_json::from_value)
1363 .transpose()
1364 .map_err(|e| format!("invalid headers: {e}"))?;
1365 options.metadata = value
1366 .get("metadata")
1367 .cloned()
1368 .map(serde_json::from_value)
1369 .transpose()
1370 .map_err(|e| format!("invalid metadata: {e}"))?;
1371 options.timeout = value
1372 .get("timeout")
1373 .and_then(|v| v.as_u64())
1374 .map(Duration::from_millis);
1375 options.max_retries = value
1376 .get("maxRetries")
1377 .and_then(|v| v.as_u64())
1378 .map(|value| value as u32);
1379 options.max_retry_delay = value
1380 .get("maxRetryDelay")
1381 .and_then(|v| v.as_u64())
1382 .map(Duration::from_millis);
1383 options.session_id = value
1384 .get("sessionId")
1385 .and_then(|v| v.as_str())
1386 .map(String::from);
1387 options.max_tokens = value.get("maxTokens").and_then(|v| v.as_u64());
1388 options.temperature = value.get("temperature").and_then(|v| v.as_f64());
1389 options.reasoning = value
1390 .get("reasoning")
1391 .cloned()
1392 .map(serde_json::from_value)
1393 .transpose()
1394 .map_err(|e| format!("invalid reasoning: {e}"))?;
1395 options.thinking_budgets = value
1396 .get("thinkingBudgets")
1397 .cloned()
1398 .map(serde_json::from_value)
1399 .transpose()
1400 .map_err(|e| format!("invalid thinkingBudgets: {e}"))?;
1401 options.cache_retention = match value.get("cacheRetention").and_then(|v| v.as_str()) {
1402 Some("long") => CacheRetention::Long,
1403 Some("none") => CacheRetention::None,
1404 _ => CacheRetention::Short,
1405 };
1406 Ok(options)
1407}
1408
1409impl crate::extension_api::ExtensionBackend for JsExtensionSession {
1410 fn backend_info(&self) -> crate::extension_api::ExtensionBackendInfo {
1411 use crate::extension_api::{ExtensionBackendInfo, ExtensionCapability};
1412
1413 let mut info = ExtensionBackendInfo::new("node");
1414 info.api_version = self.api_version;
1415 for capability in self
1416 .capabilities()
1417 .iter()
1418 .filter_map(|name| match name.as_str() {
1419 "tools" => Some(ExtensionCapability::Tools),
1420 "commands" => Some(ExtensionCapability::Commands),
1421 "resources" => Some(ExtensionCapability::Resources),
1422 "ui.notify" => Some(ExtensionCapability::UiNotify),
1423 "ui.editor" => Some(ExtensionCapability::UiEditor),
1424 "ui.custom" => Some(ExtensionCapability::UiCustom),
1425 "ui.select" => Some(ExtensionCapability::UiSelect),
1426 "ui.confirm" => Some(ExtensionCapability::UiConfirm),
1427 "ui.input" => Some(ExtensionCapability::UiInput),
1428 "ui.editor_dialog" => Some(ExtensionCapability::UiEditorDialog),
1429 "session" => Some(ExtensionCapability::Session),
1430 "models" => Some(ExtensionCapability::Models),
1431 "events" => Some(ExtensionCapability::Events),
1432 "providers" => Some(ExtensionCapability::Providers),
1433 "provider_calls" => Some(ExtensionCapability::ProviderCalls),
1434 "renderers" => Some(ExtensionCapability::Renderers),
1435 "runtime_actions" => Some(ExtensionCapability::RuntimeActions),
1436 _ => None,
1437 })
1438 {
1439 info.capabilities.insert(capability);
1440 }
1441 info
1442 }
1443}
1444
1445#[derive(Clone)]
1446pub struct JsToolAdapter {
1447 session: JsExtensionSession,
1448 definition: JsToolDefinition,
1449}
1450
1451#[async_trait]
1452impl AgentTool for JsToolAdapter {
1453 fn schema(&self) -> &Tool {
1454 &self.definition.tool
1455 }
1456 fn label(&self) -> &str {
1457 &self.definition.label
1458 }
1459 fn execution_mode(&self) -> ToolExecutionMode {
1460 self.definition.execution_mode
1461 }
1462 async fn execute(
1463 &self,
1464 tool_call_id: &str,
1465 params: serde_json::Value,
1466 signal: CancellationToken,
1467 on_update: Arc<dyn Fn(ToolResultPartial) + Send + Sync>,
1468 ) -> Result<AgentToolResult, AgentError> {
1469 let name = self.definition.name.clone();
1470 let tool_call_id = tool_call_id.to_string();
1471 let transport = self.session.transport.clone();
1472 let update_handler: Arc<dyn Fn(serde_json::Value) + Send + Sync> = {
1473 let on_update = on_update.clone();
1474 Arc::new(move |value| {
1475 if let Ok(partial) = parse_tool_result(value) {
1479 on_update(partial);
1480 }
1481 })
1482 };
1483 transport
1484 .register_tool_update_handler(tool_call_id.clone(), update_handler)
1485 .map_err(AgentError::tool)?;
1486 let pending = match transport.begin_request(
1487 "invoke_tool",
1488 serde_json::json!({"tool": name, "toolCallId": tool_call_id.clone(), "args": params}),
1489 ) {
1490 Ok(pending) => pending,
1491 Err(error) => {
1492 transport.unregister_tool_update_handler(&tool_call_id);
1493 return Err(AgentError::tool(error));
1494 }
1495 };
1496 let request_id = pending.id();
1497 let cancel_transport = transport.clone();
1498 let wait = tokio::task::spawn_blocking(move || pending.wait());
1499 let result = tokio::select! {
1500 result = wait => result
1501 .map_err(|error| AgentError::tool(error.to_string()))
1502 .and_then(|response| response.map_err(AgentError::tool)),
1503 _ = signal.cancelled() => {
1504 let _ = cancel_transport.cancel(request_id);
1505 Err(AgentError::Abort)
1506 }
1507 };
1508 transport.unregister_tool_update_handler(&tool_call_id);
1509 let result = result?;
1510 parse_tool_result(result).map_err(AgentError::tool)
1511 }
1512}
1513
1514fn start_node_transport(
1515 paths: &[PathBuf],
1516 context: &serde_json::Value,
1517 discovery_only: bool,
1518 initial_handlers: &[RuntimeHandler],
1519) -> Result<NodeTransportStartup, String> {
1520 let node = which_node()?;
1521 let host_script = if cfg!(windows) {
1526 Some(write_node_host_script()?)
1527 } else {
1528 None
1529 };
1530 let mut command = Command::new(node);
1531 if let Some(path) = host_script.as_ref() {
1532 command.arg(path);
1533 } else {
1534 command.args(["--input-type=module", "-e", NODE_HOST]);
1535 }
1536 command
1537 .env(
1538 "RPI_JS_EXTENSION_PATHS",
1539 serde_json::to_string(paths).unwrap_or_else(|_| "[]".into()),
1540 )
1541 .env(
1542 "RPI_JS_EXTENSION_CONTEXT",
1543 serde_json::to_string(context).unwrap_or_else(|_| "{}".into()),
1544 )
1545 .stdin(Stdio::piped())
1546 .stdout(Stdio::piped())
1547 .stderr(Stdio::inherit());
1550 if discovery_only {
1551 command.env("RPI_JS_EXTENSION_ONESHOT", "1");
1552 } else {
1553 command
1554 .env("RPI_JS_EXTENSION_SKIP_INITIAL_HOOK", "1")
1555 .env("RPI_JS_EXTENSION_SKIP_INITIAL_DISCOVERY", "1");
1556 }
1557 let mut child: Child = match command.spawn() {
1558 Ok(child) => child,
1559 Err(error) => {
1560 remove_node_host_script(host_script.as_ref());
1561 return Err(format!("could not start Node JS extension host: {error}"));
1562 }
1563 };
1564 let stdin: ChildStdin = match child.stdin.take() {
1565 Some(stdin) => stdin,
1566 None => {
1567 let _ = child.kill();
1568 let _ = child.wait();
1569 remove_node_host_script(host_script.as_ref());
1570 return Err("Node extension host stdin unavailable".into());
1571 }
1572 };
1573 let stdout: ChildStdout = match child.stdout.take() {
1574 Some(stdout) => stdout,
1575 None => {
1576 let _ = child.kill();
1577 let _ = child.wait();
1578 remove_node_host_script(host_script.as_ref());
1579 return Err("Node extension host stdout unavailable".into());
1580 }
1581 };
1582 let result = NodeTransport::start_pending_with_cleanup_and_handlers(
1583 child,
1584 stdin,
1585 stdout,
1586 host_script.clone(),
1587 initial_handlers.to_vec(),
1588 );
1589 if result.is_err() {
1590 remove_node_host_script(host_script.as_ref());
1591 }
1592 result
1593}
1594
1595fn write_node_host_script() -> Result<PathBuf, String> {
1596 let id = NEXT_NODE_HOST_SCRIPT.fetch_add(1, Ordering::Relaxed);
1597 let path = std::env::temp_dir().join(format!("rpi-node-host-{}-{id}.mjs", std::process::id()));
1598 let mut file = OpenOptions::new()
1599 .write(true)
1600 .create_new(true)
1601 .open(&path)
1602 .map_err(|error| format!("could not create Node host script: {error}"))?;
1603 if let Err(error) = file.write_all(NODE_HOST.as_bytes()) {
1604 let _ = std::fs::remove_file(&path);
1605 return Err(format!("could not write Node host script: {error}"));
1606 }
1607 Ok(path)
1608}
1609
1610fn remove_node_host_script(path: Option<&PathBuf>) {
1611 if let Some(path) = path {
1612 let _ = std::fs::remove_file(path);
1613 }
1614}
1615
1616fn which_node() -> Result<String, String> {
1617 for candidate in ["node", "nodejs"] {
1618 if Command::new(candidate)
1619 .arg("--version")
1620 .stdout(Stdio::null())
1621 .stderr(Stdio::null())
1622 .status()
1623 .is_ok_and(|status| status.success())
1624 {
1625 return Ok(candidate.to_string());
1626 }
1627 }
1628 Err("Pi JS/TS extensions require Node.js (node --version was not found)".into())
1629}
1630
1631fn parse_resources(value: Option<&serde_json::Value>) -> JsResources {
1632 let list = |key: &str| {
1633 value
1634 .and_then(|v| v.get(key))
1635 .and_then(serde_json::Value::as_array)
1636 .map(|items| {
1637 items
1638 .iter()
1639 .filter_map(|item| item.as_str().map(PathBuf::from))
1640 .collect()
1641 })
1642 .unwrap_or_default()
1643 };
1644 JsResources {
1645 skill_paths: list("skillPaths"),
1646 prompt_paths: list("promptPaths"),
1647 theme_paths: list("themePaths"),
1648 }
1649}
1650
1651fn parse_tool_result(value: serde_json::Value) -> Result<AgentToolResult, String> {
1652 let mut result = AgentToolResult::default();
1653 if let Some(content) = value.get("content").and_then(serde_json::Value::as_array) {
1654 for block in content {
1655 if block.get("type").and_then(serde_json::Value::as_str) == Some("image") {
1656 result
1657 .content
1658 .push(TextContentOrImage::Image(rpi_ai::types::ImageContent {
1659 kind: rpi_ai::types::ImageContentType,
1660 data: block
1661 .get("data")
1662 .and_then(serde_json::Value::as_str)
1663 .unwrap_or_default()
1664 .into(),
1665 mime_type: block
1666 .get("mimeType")
1667 .and_then(serde_json::Value::as_str)
1668 .unwrap_or("image/png")
1669 .into(),
1670 }));
1671 } else {
1672 result.content.push(TextContentOrImage::text(
1673 block
1674 .get("text")
1675 .and_then(serde_json::Value::as_str)
1676 .unwrap_or_default(),
1677 ));
1678 }
1679 }
1680 } else if let Some(text) = value.as_str() {
1681 result.content.push(TextContentOrImage::text(text));
1682 }
1683 result.details = value
1684 .get("details")
1685 .cloned()
1686 .unwrap_or(serde_json::Value::Null);
1687 result.usage = value
1688 .get("usage")
1689 .cloned()
1690 .filter(|value| !value.is_null())
1691 .and_then(|value| serde_json::from_value(value).ok());
1695 result.added_tool_names = value
1696 .get("addedToolNames")
1697 .and_then(serde_json::Value::as_array)
1698 .map(|values| {
1699 values
1700 .iter()
1701 .filter_map(serde_json::Value::as_str)
1702 .map(ToOwned::to_owned)
1703 .collect()
1704 })
1705 .unwrap_or_default();
1706 result.terminate = value
1707 .get("terminate")
1708 .and_then(serde_json::Value::as_bool)
1709 .unwrap_or(false);
1710 Ok(result)
1711}
1712
1713fn normalize_host_path(path: &PathBuf) -> PathBuf {
1714 if cfg!(windows) {
1715 let text = path.to_string_lossy();
1716 if let Some(stripped) = text.strip_prefix("\\\\?\\") {
1717 return PathBuf::from(stripped);
1718 }
1719 }
1720 path.clone()
1721}
1722
1723#[cfg(test)]
1724mod tests {
1725 use super::*;
1726 use crate::extension_api::ExtensionBackend;
1727
1728 #[test]
1729 fn lazy_runtime_context_merges_capabilities_before_start() {
1730 let transport = LazyNodeTransport::new(Vec::new(), serde_json::json!({}));
1731 transport
1732 .set_runtime_context(serde_json::json!({
1733 "capabilities": ["ui.custom"],
1734 "session": {"id": "first"}
1735 }))
1736 .unwrap();
1737 transport
1738 .set_runtime_context(serde_json::json!({
1739 "capabilities": ["ui.dialog"],
1740 "session": {"id": "second"}
1741 }))
1742 .unwrap();
1743 let pending = transport.pending_context.lock().unwrap().clone().unwrap();
1744 assert_eq!(
1745 pending["capabilities"],
1746 serde_json::json!(["ui.custom", "ui.dialog"])
1747 );
1748 assert_eq!(pending["session"]["id"], "second");
1749 }
1750
1751 #[test]
1752 fn lazy_transport_stops_host_when_final_owner_drops() {
1753 let temp = tempfile::tempdir().unwrap();
1754 let path = temp.path().join("drop.js");
1755 std::fs::write(
1756 &path,
1757 "export default (pi) => pi.registerCommand('ping', async () => ({ text: 'pong' }));",
1758 )
1759 .unwrap();
1760 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
1761 let live = session.transport.ensure().unwrap();
1762 assert!(!live.is_shutdown());
1763
1764 let retained_owner = session.transport.clone();
1768 drop(session);
1769 assert!(!live.is_shutdown());
1770 drop(retained_owner);
1771
1772 assert!(
1773 live.is_shutdown(),
1774 "the final LazyNodeTransport owner must stop the Node host"
1775 );
1776 assert!(live
1777 .request(
1778 "invoke_command",
1779 serde_json::json!({"command": "ping", "args": ""})
1780 )
1781 .is_err());
1782 }
1783
1784 #[test]
1785 fn parse_tool_result_preserves_supported_metadata_and_ignores_unknown_usage() {
1786 let result = parse_tool_result(serde_json::json!({
1787 "content": [{"type": "text", "text": "ok"}],
1788 "details": {"source": "js"},
1789 "usage": {"totalTokens": "later-version"},
1790 "addedToolNames": ["next", 7],
1791 }))
1792 .unwrap();
1793 assert!(result.usage.is_none());
1794 assert_eq!(result.added_tool_names, ["next"]);
1795 assert_eq!(result.details["source"], "js");
1796 }
1797
1798 #[test]
1799 fn loads_javascript_tool_and_invokes_it() {
1800 let temp = tempfile::tempdir().unwrap();
1801 let path = temp.path().join("extension.js");
1802 std::fs::write(
1803 &path,
1804 "export default (pi) => pi.registerTool({name: 'hello', description: 'test', executionMode: 'sequential', parameters: {type: 'object'}, async execute() { return {content: [{type: 'text', text: 'ok'}], details: {}}; }});",
1805 )
1806 .unwrap();
1807 let session = JsExtensionSession::load(&[path], true).unwrap().unwrap();
1808 let tool = session.tools().next().unwrap();
1809 assert_eq!(tool.schema().name, "hello");
1810 assert_eq!(tool.execution_mode(), ToolExecutionMode::Sequential);
1811 let value = session
1812 .invoke(
1813 "invoke_tool",
1814 serde_json::json!({"tool":"hello", "args":{}}),
1815 )
1816 .unwrap();
1817 assert_eq!(value["content"][0]["text"], "ok");
1818 }
1819
1820 #[test]
1821 fn failed_extension_rolls_back_registrations_and_keeps_loading() {
1822 let temp = tempfile::tempdir().unwrap();
1823 let broken = temp.path().join("a-broken.js");
1824 let healthy = temp.path().join("b-healthy.js");
1825 std::fs::write(
1826 &broken,
1827 r#"export default (pi) => {
1828 pi.registerCommand('leaked-command', async () => ({ text: 'leaked' }));
1829 pi.registerTool({
1830 name: 'leaked-tool',
1831 description: 'must be rolled back',
1832 parameters: { type: 'object', properties: {} },
1833 async execute() { return { text: 'leaked' }; }
1834 });
1835 throw new Error('broken factory');
1836 };"#,
1837 )
1838 .unwrap();
1839 std::fs::write(
1840 &healthy,
1841 "export default (pi) => pi.registerCommand('healthy', async () => ({ text: 'pong' }));",
1842 )
1843 .unwrap();
1844
1845 let session = JsExtensionSession::load(&[broken, healthy], false)
1846 .unwrap()
1847 .unwrap();
1848 assert_eq!(session.commands, ["healthy"]);
1849 assert!(!session
1850 .tool_names()
1851 .iter()
1852 .any(|name| name == "leaked-tool"));
1853 let value = session.invoke_command("healthy", "").unwrap();
1854 assert_eq!(value["result"]["text"], "pong");
1855 assert!(session.invoke_command("leaked-command", "").is_err());
1856 }
1857
1858 #[test]
1859 fn failed_extension_rollback_preserves_existing_event_unsubscribe() {
1860 let temp = tempfile::tempdir().unwrap();
1861 let healthy = temp.path().join("a-healthy.js");
1862 let broken = temp.path().join("b-broken.js");
1863 std::fs::write(
1864 &healthy,
1865 r#"export default (pi) => {
1866 let calls = 0;
1867 const off = pi.events.on('probe', () => { calls += 1; });
1868 pi.registerCommand('unsubscribe', async () => {
1869 off();
1870 await pi.events.emit('probe');
1871 return { text: String(calls) };
1872 });
1873 };"#,
1874 )
1875 .unwrap();
1876 std::fs::write(
1877 &broken,
1878 r#"export default (pi) => {
1879 pi.events.on('probe', () => {});
1880 throw new Error('broken factory');
1881 };"#,
1882 )
1883 .unwrap();
1884
1885 let session = JsExtensionSession::load(&[healthy, broken], false)
1886 .unwrap()
1887 .unwrap();
1888 let value = session.invoke_command("unsubscribe", "").unwrap();
1889 assert_eq!(value["result"]["text"], "0");
1890 }
1891
1892 #[test]
1893 fn directory_discovery_does_not_recurse_beyond_one_child_level() {
1894 let temp = tempfile::tempdir().unwrap();
1895 let root = temp.path().join("extensions");
1896 let deep = root.join("child/deep");
1897 std::fs::create_dir_all(&deep).unwrap();
1898 std::fs::write(
1899 root.join("direct.js"),
1900 "export default (pi) => pi.registerCommand('direct', async () => ({ text: 'ok' }));",
1901 )
1902 .unwrap();
1903 std::fs::write(
1904 deep.join("index.js"),
1905 "export default (pi) => pi.registerCommand('too-deep', async () => ({ text: 'bad' }));",
1906 )
1907 .unwrap();
1908
1909 let session = JsExtensionSession::load(&[root], false).unwrap().unwrap();
1910 assert_eq!(session.commands, ["direct"]);
1911 }
1912
1913 #[test]
1914 fn directory_manifest_prefers_rpi_extension_entries() {
1915 let temp = tempfile::tempdir().unwrap();
1916 let root = temp.path().join("package");
1917 std::fs::create_dir_all(&root).unwrap();
1918 std::fs::write(
1919 root.join("package.json"),
1920 r#"{"pi":{"extensions":["pi.js"]},"rpi":{"extensions":["rpi.js"]}}"#,
1921 )
1922 .unwrap();
1923 std::fs::write(
1924 root.join("pi.js"),
1925 "export default (pi) => pi.registerCommand('pi-entry', async () => ({ text: 'pi' }));",
1926 )
1927 .unwrap();
1928 std::fs::write(
1929 root.join("rpi.js"),
1930 "export default (pi) => pi.registerCommand('rpi-entry', async () => ({ text: 'rpi' }));",
1931 )
1932 .unwrap();
1933
1934 let session = JsExtensionSession::load(&[root], false).unwrap().unwrap();
1935 assert_eq!(session.commands, ["rpi-entry"]);
1936 }
1937
1938 #[tokio::test]
1939 async fn javascript_tool_forwards_on_update_partials() {
1940 let temp = tempfile::tempdir().unwrap();
1941 let path = temp.path().join("progress.js");
1942 std::fs::write(
1943 &path,
1944 r#"export default (pi) => pi.registerTool({
1945 name: 'progress',
1946 description: 'emit progress',
1947 parameters: { type: 'object', properties: {} },
1948 async execute(_id, _args, _signal, onUpdate) {
1949 onUpdate({ content: [{ type: 'text', text: 'first' }], details: { step: 1 } });
1950 await new Promise(resolve => setTimeout(resolve, 20));
1951 onUpdate({ content: [{ type: 'text', text: 'second' }], details: { step: 2 } });
1952 return {
1953 content: [{ type: 'text', text: 'done' }],
1954 details: {},
1955 usage: {
1956 input: 1, output: 2, cacheRead: 3, cacheWrite: 4,
1957 totalTokens: 10,
1958 cost: { input: 0.1, output: 0.2, cacheRead: 0, cacheWrite: 0, total: 0.3 }
1959 },
1960 addedToolNames: ['follow_up']
1961 };
1962 }
1963 });"#,
1964 )
1965 .unwrap();
1966 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
1967 let updates = Arc::new(Mutex::new(Vec::<String>::new()));
1968 let observed = updates.clone();
1969 let tool = session.tools().next().expect("registered JS tool");
1970 let result = tool
1971 .execute(
1972 "progress-call",
1973 serde_json::json!({}),
1974 CancellationToken::new(),
1975 Arc::new(move |partial| {
1976 let text = partial
1977 .content
1978 .first()
1979 .and_then(|content| match content {
1980 TextContentOrImage::Text(text) => Some(text.text.clone()),
1981 TextContentOrImage::Image(_) => None,
1982 })
1983 .unwrap_or_default();
1984 observed.lock().unwrap().push(text);
1985 }),
1986 )
1987 .await
1988 .unwrap();
1989 assert_eq!(
1990 result.content.first().and_then(|content| match content {
1991 TextContentOrImage::Text(text) => Some(text.text.as_str()),
1992 TextContentOrImage::Image(_) => None,
1993 }),
1994 Some("done")
1995 );
1996 assert_eq!(
1997 result.usage.as_ref().map(|usage| usage.total_tokens),
1998 Some(10)
1999 );
2000 assert_eq!(result.added_tool_names, ["follow_up"]);
2001 assert_eq!(updates.lock().unwrap().as_slice(), ["first", "second"]);
2002 }
2003
2004 #[tokio::test]
2005 async fn javascript_tool_receives_ui_context() {
2006 let temp = tempfile::tempdir().unwrap();
2007 let path = temp.path().join("ui-tool.js");
2008 std::fs::write(
2009 &path,
2010 r#"export default (pi) => pi.registerTool({
2011 name: 'ui_probe',
2012 description: 'verify the Pi tool context',
2013 parameters: { type: 'object', properties: {} },
2014 async execute(_toolCallId, _args, _signal, _onUpdate, ctx) {
2015 if (ctx?.hasUI !== true) throw new Error('tool context missing hasUI');
2016 if (typeof ctx?.ui?.custom !== 'function') throw new Error('tool context missing ui.custom');
2017 if (!ctx.capabilities?.has('ui.custom')) throw new Error('tool context missing ui.custom capability');
2018 return { content: [{ type: 'text', text: 'ui-context-ok' }], details: {} };
2019 }
2020 });"#,
2021 )
2022 .unwrap();
2023 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2024 session
2028 .set_runtime_context(serde_json::json!({
2029 "mode": "tui",
2030 "hasUI": true,
2031 "capabilities": ["ui.custom"],
2032 }))
2033 .unwrap();
2034
2035 let tool = session.tools().next().expect("registered JS tool");
2036 let result = tool
2037 .execute(
2038 "tool-call-1",
2039 serde_json::json!({}),
2040 CancellationToken::new(),
2041 Arc::new(|_| {}),
2042 )
2043 .await
2044 .expect("tool should receive a UI context");
2045 match result.content.first() {
2046 Some(TextContentOrImage::Text(text)) => assert_eq!(text.text, "ui-context-ok"),
2047 other => panic!("unexpected tool content: {other:?}"),
2048 }
2049 }
2050
2051 #[test]
2052 fn loads_typescript_when_node_supports_type_stripping() {
2053 let temp = tempfile::tempdir().unwrap();
2054 let path = temp.path().join("extension.ts");
2055 std::fs::write(
2056 &path,
2057 "export default (pi: any) => pi.registerTool({name: 'typed', description: 'test', parameters: {type: 'object'}, async execute() { return {content: [{type: 'text', text: 'typed'}]}; }});",
2058 )
2059 .unwrap();
2060 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2061 assert_eq!(session.tools().next().unwrap().schema().name, "typed");
2062 }
2063
2064 #[test]
2065 fn command_context_roundtrips_editor_and_capabilities() {
2066 let temp = tempfile::tempdir().unwrap();
2067 let path = temp.path().join("command.js");
2068 std::fs::write(
2069 &path,
2070 "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'}; });",
2071 )
2072 .unwrap();
2073 let session = JsExtensionSession::load_with_context(
2074 &[path],
2075 false,
2076 serde_json::json!({
2077 "currentModel":{"id":"model","provider":"anthropic"},
2078 "models":[{"id":"model","provider":"anthropic"}]
2079 }),
2080 )
2081 .unwrap()
2082 .unwrap();
2083 assert!(session.transport.transport.lock().unwrap().is_none());
2084 session
2085 .set_runtime_context(serde_json::json!({
2086 "session":{"id":"session-1","leafId":"leaf-1","entries":[{"id":"entry-1"}]}
2087 }))
2088 .unwrap();
2089 assert!(session.transport.transport.lock().unwrap().is_none());
2090 let value = session
2091 .invoke_command_with_context("edit", "", serde_json::json!({"editorText":"draft"}))
2092 .unwrap();
2093 assert_eq!(value["result"]["text"], "done");
2094 assert_eq!(value["editorText"], "draft!");
2095 assert_eq!(value["notifications"][0]["message"], "updated");
2096 assert!(session
2097 .backend_info()
2098 .supports(crate::extension_api::ExtensionCapability::UiEditor));
2099 }
2100
2101 #[test]
2102 fn command_context_is_non_ui_until_a_tui_bridge_is_installed() {
2103 let temp = tempfile::tempdir().unwrap();
2104 let path = temp.path().join("ui-mode.js");
2105 std::fs::write(
2106 &path,
2107 r#"export default (pi) => pi.registerCommand('mode', async (_args, ctx) => ({
2108 hasUI: ctx.hasUI,
2109 mode: ctx.mode,
2110 hasCustom: typeof ctx.ui.custom === 'function'
2111 }));"#,
2112 )
2113 .unwrap();
2114 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2115 let plain = session.invoke_command("mode", "").unwrap();
2116 assert_eq!(plain["result"]["hasUI"], false);
2117 assert_eq!(plain["result"]["mode"], "print");
2118 assert_eq!(plain["result"]["hasCustom"], true);
2119
2120 session
2121 .install_ui_dialog_runtime(Arc::new(|action, _args| {
2122 Err(format!("unsupported capability: {action}"))
2123 }))
2124 .unwrap();
2125 let tui = session.invoke_command("mode", "").unwrap();
2126 assert_eq!(tui["result"]["hasUI"], true);
2127 assert_eq!(tui["result"]["mode"], "tui");
2128 }
2129
2130 #[test]
2131 fn before_agent_start_reconciles_js_active_tools_when_ui_changes() {
2132 let temp = tempfile::tempdir().unwrap();
2133 let path = temp.path().join("active-tools.js");
2134 std::fs::write(
2135 &path,
2136 r#"export default (pi) => {
2137 pi.registerTool({
2138 name: 'ask_user_question',
2139 description: 'probe',
2140 parameters: { type: 'object', properties: {} },
2141 async execute() { return { content: [{ type: 'text', text: 'ok' }] }; }
2142 });
2143 pi.on('before_agent_start', (_event, ctx) => {
2144 const active = pi.getActiveTools();
2145 if (!ctx.hasUI) {
2146 pi.setActiveTools(active.filter((name) => name !== 'ask_user_question'));
2147 } else if (!active.includes('ask_user_question')) {
2148 pi.setActiveTools([...active, 'ask_user_question']);
2149 }
2150 });
2151 };"#,
2152 )
2153 .unwrap();
2154 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2155 assert!(!session
2156 .active_tools()
2157 .unwrap()
2158 .iter()
2159 .any(|name| name == "ask_user_question"));
2160
2161 let tui = Arc::new(rpi_tui::TuiAltScreen::new(
2162 Box::new(rpi_tui::ProcessTerminal::new()),
2163 true,
2164 None,
2165 ));
2166 session.install_ui_runtime(tui).unwrap();
2167 assert!(session.transport.transport.lock().unwrap().is_none());
2168 session.ensure_runtime().unwrap();
2169 assert!(session
2170 .active_tools()
2171 .unwrap()
2172 .iter()
2173 .any(|name| name == "ask_user_question"));
2174 }
2175
2176 #[test]
2177 fn persistent_host_runs_before_agent_start_without_pending_context() {
2178 let temp = tempfile::tempdir().unwrap();
2179 let marker = temp.path().join("before-agent-start-count");
2180 let path = temp.path().join("before-agent-start.js");
2181 let marker_literal = serde_json::to_string(&marker.to_string_lossy()).unwrap();
2182 std::fs::write(&marker, "0").unwrap();
2183 std::fs::write(
2184 &path,
2185 format!(
2186 r#"import fs from 'node:fs';
2187 export default (pi) => {{
2188 pi.registerCommand('ping', async () => ({{ text: 'pong' }}));
2189 pi.on('before_agent_start', () => {{
2190 const file = {marker};
2191 const count = Number(fs.readFileSync(file, 'utf8') || '0') + 1;
2192 fs.writeFileSync(file, String(count));
2193 }});
2194 }};"#,
2195 marker = marker_literal,
2196 ),
2197 )
2198 .unwrap();
2199
2200 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2201 assert_eq!(
2202 std::fs::read_to_string(&marker).unwrap(),
2203 "1",
2204 "discovery host should run the lifecycle hook once"
2205 );
2206
2207 session.invoke_command("ping", "").unwrap();
2210 assert_eq!(
2211 std::fs::read_to_string(&marker).unwrap(),
2212 "2",
2213 "persistent host skipped before_agent_start when no context was queued"
2214 );
2215 }
2216
2217 #[test]
2218 fn prepare_for_prompt_runs_lifecycle_once_per_prompt() {
2219 let temp = tempfile::tempdir().unwrap();
2220 let marker = temp.path().join("prompt-lifecycle-count");
2221 let path = temp.path().join("prompt-lifecycle.js");
2222 let marker_literal = serde_json::to_string(&marker.to_string_lossy()).unwrap();
2223 std::fs::write(&marker, "0").unwrap();
2224 std::fs::write(
2225 &path,
2226 format!(
2227 r#"import fs from 'node:fs';
2228 export default (pi) => {{
2229 pi.on('before_agent_start', () => {{
2230 const file = {marker};
2231 const count = Number(fs.readFileSync(file, 'utf8') || '0') + 1;
2232 fs.writeFileSync(file, String(count));
2233 }});
2234 }};"#,
2235 marker = marker_literal,
2236 ),
2237 )
2238 .unwrap();
2239
2240 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2241 assert_eq!(std::fs::read_to_string(&marker).unwrap(), "1");
2242
2243 session.prepare_for_prompt().unwrap();
2244 assert_eq!(std::fs::read_to_string(&marker).unwrap(), "2");
2245
2246 session.prepare_for_prompt().unwrap();
2247 assert_eq!(std::fs::read_to_string(&marker).unwrap(), "3");
2248 }
2249
2250 #[test]
2251 fn concurrent_runtime_context_hooks_apply_latest_active_tools() {
2252 let temp = tempfile::tempdir().unwrap();
2253 let marker = temp.path().join("context-order.log");
2254 let marker_literal = serde_json::to_string(&marker.to_string_lossy()).unwrap();
2255 let path = temp.path().join("context-order.js");
2256 let source = r#"import fs from 'node:fs';
2257 export default (pi) => {
2258 pi.registerTool({ name: 'v1', description: 'v1', parameters: { type: 'object', properties: {} }, async execute() { return { text: 'v1' }; } });
2259 pi.registerTool({ name: 'v2', description: 'v2', parameters: { type: 'object', properties: {} }, async execute() { return { text: 'v2' }; } });
2260 pi.on('before_agent_start', async (_event, ctx) => {
2261 const version = ctx.sessionManager.getSessionId();
2262 fs.writeFileSync(__MARKER__, version);
2263 if (version === 'v1') await new Promise(resolve => setTimeout(resolve, 200));
2264 pi.setActiveTools([version]);
2265 });
2266 };"#
2267 .replace("__MARKER__", &marker_literal);
2268 std::fs::write(&path, source).unwrap();
2269
2270 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2271 session.ensure_runtime().unwrap();
2272
2273 let first = session.clone();
2274 let first_worker = std::thread::spawn(move || {
2275 first.set_runtime_context(serde_json::json!({ "session": { "id": "v1" } }))
2276 });
2277 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
2278 while std::fs::read_to_string(&marker).ok().as_deref() != Some("v1")
2279 && std::time::Instant::now() < deadline
2280 {
2281 std::thread::sleep(std::time::Duration::from_millis(5));
2282 }
2283 assert_eq!(std::fs::read_to_string(&marker).unwrap(), "v1");
2284
2285 let second = session.clone();
2286 let second_worker = std::thread::spawn(move || {
2287 second.set_runtime_context(serde_json::json!({ "session": { "id": "v2" } }))
2288 });
2289 first_worker.join().unwrap().unwrap();
2290 second_worker.join().unwrap().unwrap();
2291
2292 assert_eq!(session.active_tools().unwrap(), ["v2"]);
2293 }
2294
2295 #[test]
2299 fn superseded_revision_cannot_publish_its_tool_projection() {
2300 let transport = LazyNodeTransport::new(Vec::new(), serde_json::json!({}));
2301 let newest = std::sync::atomic::Ordering::Release;
2302 transport.context_revision.store(2, newest);
2303
2304 transport.record_active_tools_for_revision(2, Some(&serde_json::json!(["v2"])));
2305 assert_eq!(transport.active_tools().unwrap(), ["v2"]);
2306
2307 transport.record_active_tools_for_revision(1, Some(&serde_json::json!(["v1"])));
2310 assert_eq!(transport.active_tools().unwrap(), ["v2"]);
2311 }
2312
2313 #[test]
2314 fn stale_runtime_context_revision_cannot_overwrite_newer_state() {
2315 let temp = tempfile::tempdir().unwrap();
2316 let path = temp.path().join("context-revision.js");
2317 std::fs::write(
2318 &path,
2319 r#"export default (pi) => {
2320 pi.on('before_agent_start', (_event, ctx) => {
2321 pi.setActiveTools([ctx.sessionManager.getSessionId()]);
2322 });
2323 };"#,
2324 )
2325 .unwrap();
2326 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2327 let transport = session.transport.ensure().unwrap();
2328
2329 let newer = transport
2330 .request(
2331 "set_runtime_context",
2332 serde_json::json!({
2333 "revision": 2,
2334 "context": {"session": {"id": "newer"}}
2335 }),
2336 )
2337 .unwrap();
2338 assert_eq!(newer["activeTools"], serde_json::json!(["newer"]));
2339
2340 let stale = transport
2341 .request(
2342 "set_runtime_context",
2343 serde_json::json!({
2344 "revision": 1,
2345 "context": {"session": {"id": "stale"}}
2346 }),
2347 )
2348 .unwrap();
2349 assert_eq!(stale["activeTools"], serde_json::json!(["newer"]));
2350 }
2351
2352 #[test]
2353 fn restarted_host_replays_context_queued_before_first_start() {
2354 let temp = tempfile::tempdir().unwrap();
2355 let path = temp.path().join("context-restart.js");
2356 std::fs::write(
2357 &path,
2358 r#"export default (pi) => {
2359 pi.registerCommand('session-id', async (_args, ctx) => ({
2360 text: ctx.sessionManager.getSessionId()
2361 }));
2362 };"#,
2363 )
2364 .unwrap();
2365 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2366 session
2367 .set_runtime_context(serde_json::json!({"session": {"id": "latest"}}))
2368 .unwrap();
2369
2370 let first = session.invoke_command("session-id", "").unwrap();
2371 assert_eq!(first["result"]["text"], "latest");
2372 let first_transport = session.transport.live_transport().unwrap().unwrap();
2373 first_transport.shutdown();
2374
2375 let restarted = session.invoke_command("session-id", "").unwrap();
2376 assert_eq!(restarted["result"]["text"], "latest");
2377 }
2378
2379 #[test]
2380 fn failed_persistent_startup_requeues_initial_runtime_context() {
2381 let temp = tempfile::tempdir().unwrap();
2382 let path = temp.path().join("startup-failure.js");
2383 std::fs::write(
2384 &path,
2385 r#"export default async (pi) => {
2386 // Discovery is deliberately healthy, while the persistent
2387 // process exits before publishing its initialization envelope.
2388 // A thrown factory error is isolated per extension by the host
2389 // and therefore is not a transport-startup failure.
2390 if (process.env.RPI_JS_EXTENSION_ONESHOT !== '1') {
2391 process.exit(17);
2392 }
2393 pi.registerCommand('ping', async () => ({ text: 'pong' }));
2394 };"#,
2395 )
2396 .unwrap();
2397 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2398 let context = serde_json::json!({
2399 "mode": "tui",
2400 "hasUI": true,
2401 "capabilities": ["ui.custom"],
2402 "session": {"id": "startup-session"},
2403 });
2404 session.set_runtime_context(context.clone()).unwrap();
2405
2406 let error = session.invoke_command("ping", "").unwrap_err();
2407 assert!(
2408 error.contains("exited before initialization")
2409 || error.contains("exited without a response")
2410 || error.contains("persistent host startup failure"),
2411 "unexpected startup error: {error}"
2412 );
2413 let pending = session
2414 .transport
2415 .pending_context
2416 .lock()
2417 .unwrap()
2418 .clone()
2419 .expect("failed startup must preserve runtime context");
2420 assert_eq!(pending["mode"], "tui");
2421 assert_eq!(pending["session"]["id"], "startup-session");
2422 assert_eq!(pending["capabilities"], serde_json::json!(["ui.custom"]));
2423 }
2424
2425 #[test]
2426 fn dialog_ui_roundtrips_through_runtime_handler() {
2427 let temp = tempfile::tempdir().unwrap();
2428 let path = temp.path().join("dialog.js");
2429 std::fs::write(
2430 &path,
2431 r#"export default (pi) => {
2432 pi.registerCommand('select', async (_args, ctx) => ({ text: await ctx.ui.select('Pick one', ['red', 'blue']) }));
2433 pi.registerCommand('confirm', async (_args, ctx) => ({ text: String(await ctx.ui.confirm('Continue?', 'Do it?')) }));
2434 pi.registerCommand('input', async (_args, ctx) => ({ text: await ctx.ui.input('Name', 'placeholder') }));
2435 pi.registerCommand('editor', async (_args, ctx) => ({ text: await ctx.ui.editor('Notes', 'prefilled') }));
2436 };"#,
2437 )
2438 .unwrap();
2439 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2440 let actions = Arc::new(Mutex::new(Vec::<(String, serde_json::Value)>::new()));
2441 let observed = actions.clone();
2442 session
2443 .install_ui_dialog_runtime(Arc::new(move |action, args| {
2444 observed
2445 .lock()
2446 .unwrap()
2447 .push((action.to_string(), args.clone()));
2448 match action {
2449 "ui.dialog" => match args.get("method").and_then(serde_json::Value::as_str) {
2450 Some("select") => Ok(serde_json::json!({"value":"blue"})),
2451 Some("confirm") => Ok(serde_json::json!({"confirmed":true})),
2452 Some("input") => Ok(serde_json::json!({"value":"Ada"})),
2453 Some("editor") => Ok(serde_json::json!({"value":"edited"})),
2454 _ => Err("unknown dialog method".into()),
2455 },
2456 _ => Err(format!("unsupported capability: {action}")),
2457 }
2458 }))
2459 .unwrap();
2460
2461 let backend = session.backend_info();
2462 assert!(backend.supports(crate::extension_api::ExtensionCapability::UiSelect));
2463 assert!(backend.supports(crate::extension_api::ExtensionCapability::UiConfirm));
2464 assert!(backend.supports(crate::extension_api::ExtensionCapability::UiInput));
2465 assert!(backend.supports(crate::extension_api::ExtensionCapability::UiEditorDialog));
2466
2467 assert_eq!(
2468 session.invoke_command("select", "").unwrap()["result"]["text"],
2469 "blue"
2470 );
2471 assert_eq!(
2472 session.invoke_command("confirm", "").unwrap()["result"]["text"],
2473 "true"
2474 );
2475 assert_eq!(
2476 session.invoke_command("input", "").unwrap()["result"]["text"],
2477 "Ada"
2478 );
2479 assert_eq!(
2480 session.invoke_command("editor", "").unwrap()["result"]["text"],
2481 "edited"
2482 );
2483
2484 let actions = actions.lock().unwrap();
2485 assert_eq!(actions.len(), 4);
2486 assert!(actions.iter().all(|(action, _)| action == "ui.dialog"));
2487 assert_eq!(actions[0].1["method"], "select");
2488 assert_eq!(actions[0].1["options"], serde_json::json!(["red", "blue"]));
2489 assert_eq!(actions[2].1["placeholder"], "placeholder");
2490 assert_eq!(actions[3].1["prefill"], "prefilled");
2491 }
2492
2493 #[test]
2494 fn dialog_timeout_sends_cancel_request() {
2495 let temp = tempfile::tempdir().unwrap();
2496 let path = temp.path().join("dialog-timeout.js");
2497 std::fs::write(
2498 &path,
2499 r#"export default (pi) => pi.registerCommand('confirm', async (_args, ctx) => ({ text: String(await ctx.ui.confirm('Continue?', 'wait', { timeout: 20 })) }));"#,
2500 )
2501 .unwrap();
2502 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2503 let actions = Arc::new(Mutex::new(Vec::<String>::new()));
2504 let observed = actions.clone();
2505 session
2506 .install_ui_dialog_runtime(Arc::new(move |action, _args| {
2507 observed.lock().unwrap().push(action.to_string());
2508 match action {
2509 "ui.dialog" => {
2510 std::thread::sleep(std::time::Duration::from_millis(100));
2511 Ok(serde_json::json!({"confirmed":true}))
2512 }
2513 "ui.dialog.cancel" => Ok(serde_json::json!(true)),
2514 _ => Err(format!("unsupported capability: {action}")),
2515 }
2516 }))
2517 .unwrap();
2518 let value = session.invoke_command("confirm", "").unwrap();
2519 assert_eq!(value["result"]["text"], "false");
2520 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
2521 while std::time::Instant::now() < deadline {
2522 let complete = {
2523 let actions = actions.lock().unwrap();
2524 actions.iter().any(|action| action == "ui.dialog")
2525 && actions.iter().any(|action| action == "ui.dialog.cancel")
2526 };
2527 if complete {
2528 break;
2529 }
2530 std::thread::sleep(std::time::Duration::from_millis(10));
2531 }
2532 let actions = actions.lock().unwrap();
2533 assert!(actions.iter().any(|action| action == "ui.dialog"));
2534 assert!(actions.iter().any(|action| action == "ui.dialog.cancel"));
2535 }
2536
2537 #[test]
2538 fn runtime_request_roundtrips_through_rust_handler() {
2539 let temp = tempfile::tempdir().unwrap();
2540 let path = temp.path().join("runtime.js");
2541 std::fs::write(
2542 &path,
2543 "export default (pi) => pi.registerCommand('runtime', async () => ({text: await pi.runtimeRequest('echo', {value: 7})}));",
2544 )
2545 .unwrap();
2546 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2547 session
2548 .set_runtime_handler(Arc::new(|action, args| {
2549 if action == "echo" {
2550 Ok(args.get("value").cloned().unwrap_or_default())
2551 } else {
2552 Err(format!("unsupported capability: {action}"))
2553 }
2554 }))
2555 .unwrap();
2556 let value = session.invoke_command("runtime", "").unwrap();
2557 assert_eq!(value["result"]["text"], 7);
2558 }
2559
2560 #[test]
2561 fn startup_runtime_request_is_not_mistaken_for_init_and_uses_initial_handler() {
2562 let temp = tempfile::tempdir().unwrap();
2563 let path = temp.path().join("startup-runtime.js");
2564 std::fs::write(
2565 &path,
2566 r#"export default async (pi) => {
2567 const value = await pi.runtimeRequest('startup.echo', { value: 'persistent' })
2568 .catch(() => 'discovery');
2569 pi.registerCommand('startup', async () => ({ text: String(value) }));
2570 };"#,
2571 )
2572 .unwrap();
2573
2574 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2578 session
2579 .set_runtime_handler(Arc::new(|action, args| {
2580 if action == "startup.echo" {
2581 Ok(args.get("value").cloned().unwrap_or_default())
2582 } else {
2583 Err(format!("unsupported capability: {action}"))
2584 }
2585 }))
2586 .unwrap();
2587
2588 let value = session.invoke_command("startup", "").unwrap();
2591 assert_eq!(value["result"]["text"], "persistent");
2592 }
2593
2594 #[test]
2595 fn provider_runtime_adapts_node_model_registry_to_rust_provider() {
2596 use rpi_ai::providers::faux::{FauxProvider, FauxScript};
2597
2598 let temp = tempfile::tempdir().unwrap();
2599 let path = temp.path().join("provider.js");
2600 std::fs::write(
2601 &path,
2602 r#"export default (pi) => pi.registerCommand('complete', async (_args, ctx) => {
2603 const model = ctx.model;
2604 const provider = ctx.modelRegistry.getProvider(model.provider);
2605 if (!provider) throw new Error('provider missing');
2606 const stream = provider.streamSimple(model, { messages: [] }, {});
2607 let eventCount = 0;
2608 for await (const event of stream) eventCount += 1;
2609 const message = await stream.result();
2610 return { text: message.content?.[0]?.text || '', stopReason: message.stopReason, eventCount };
2611 });"#,
2612 )
2613 .unwrap();
2614
2615 let runtime = tokio::runtime::Runtime::new().unwrap();
2616 let provider = FauxProvider::new(FauxScript::new().with_text("from rust"));
2617 let model = provider.default_model().clone();
2618 let session = JsExtensionSession::load_with_context(
2619 &[path],
2620 false,
2621 serde_json::json!({
2622 "currentModel": model,
2623 "models": [model],
2624 }),
2625 )
2626 .unwrap()
2627 .unwrap();
2628 session
2629 .enable_provider_runtime(provider.clone(), runtime.handle().clone())
2630 .unwrap();
2631
2632 let value = session.invoke_command("complete", "").unwrap();
2635 assert_eq!(value["result"]["text"], "from rust");
2636 assert_eq!(value["result"]["stopReason"], "stop");
2637 assert!(value["result"]["eventCount"].as_u64().unwrap() >= 2);
2638 assert_eq!(
2639 provider
2640 .state()
2641 .call_count
2642 .load(std::sync::atomic::Ordering::Relaxed),
2643 1
2644 );
2645 drop(session);
2646 runtime.shutdown_timeout(std::time::Duration::from_secs(1));
2647 }
2648
2649 #[test]
2650 fn node_transport_multiplexes_out_of_order_command_responses() {
2651 let temp = tempfile::tempdir().unwrap();
2652 let path = temp.path().join("concurrent.js");
2653 std::fs::write(
2654 &path,
2655 r#"export default (pi) => {
2656 pi.registerCommand('slow', async () => {
2657 await new Promise(resolve => setTimeout(resolve, 300));
2658 return { text: 'slow' };
2659 });
2660 pi.registerCommand('fast', async () => ({ text: 'fast' }));
2661 };"#,
2662 )
2663 .unwrap();
2664 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2665 assert!(session.transport.transport.lock().unwrap().is_none());
2666 let (sender, receiver) = std::sync::mpsc::channel();
2667
2668 let slow = session.clone();
2669 let slow_sender = sender.clone();
2670 std::thread::spawn(move || {
2671 let value = slow.invoke_command("slow", "").unwrap();
2672 slow_sender.send(value["result"]["text"].clone()).unwrap();
2673 });
2674 std::thread::sleep(std::time::Duration::from_millis(50));
2675 let fast = session.clone();
2676 std::thread::spawn(move || {
2677 let value = fast.invoke_command("fast", "").unwrap();
2678 sender.send(value["result"]["text"].clone()).unwrap();
2679 });
2680
2681 let first = receiver
2682 .recv_timeout(std::time::Duration::from_secs(2))
2683 .expect("fast request should not wait for the slow request");
2684 assert_eq!(first, serde_json::json!("fast"));
2685 assert_eq!(receiver.recv().unwrap(), serde_json::json!("slow"));
2686 }
2687
2688 #[test]
2689 fn shutting_down_session_wakes_detached_command_and_blocks_restart() {
2690 let temp = tempfile::tempdir().unwrap();
2691 let path = temp.path().join("shutdown.js");
2692 std::fs::write(
2693 &path,
2694 r#"export default (pi) => pi.registerCommand('wait', async () => {
2695 await new Promise(resolve => setTimeout(resolve, 10000));
2696 return { text: 'late' };
2697 });"#,
2698 )
2699 .unwrap();
2700 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2701 let worker_session = session.clone();
2702 let (sender, receiver) = std::sync::mpsc::channel();
2703 let worker = std::thread::spawn(move || {
2704 sender
2705 .send(worker_session.invoke_command("wait", ""))
2706 .unwrap();
2707 });
2708
2709 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
2712 while session.transport.transport.lock().unwrap().is_none()
2713 && std::time::Instant::now() < deadline
2714 {
2715 std::thread::sleep(std::time::Duration::from_millis(10));
2716 }
2717 assert!(
2718 session.transport.transport.lock().unwrap().is_some(),
2719 "lazy Node transport did not start"
2720 );
2721
2722 session.shutdown();
2723 session.shutdown();
2724 let result = receiver
2725 .recv_timeout(std::time::Duration::from_secs(2))
2726 .expect("shutdown should wake the detached command");
2727 assert!(result.is_err(), "stopped command unexpectedly succeeded");
2728 worker.join().unwrap();
2729 assert!(
2730 session.transport.ensure().is_err(),
2731 "shutdown must prevent lazy Node restart"
2732 );
2733 }
2734
2735 #[test]
2736 fn shutting_down_during_lazy_initialization_does_not_wait_for_slot_lock() {
2737 let temp = tempfile::tempdir().unwrap();
2738 let path = temp.path().join("shutdown-init.js");
2739 std::fs::write(
2740 &path,
2741 r#"export default (pi) => {
2742 pi.on('before_agent_start', async (_event, ctx) => {
2743 if (ctx.hasUI) {
2744 await new Promise(resolve => setTimeout(resolve, 5000));
2745 }
2746 });
2747 pi.registerCommand('wait', async () => ({ text: 'late' }));
2748 };"#,
2749 )
2750 .unwrap();
2751 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2752 session
2755 .set_runtime_context(serde_json::json!({
2756 "mode": "tui",
2757 "hasUI": true,
2758 "capabilities": ["ui.custom"],
2759 }))
2760 .unwrap();
2761 let worker_session = session.clone();
2762 let (command_sender, command_receiver) = std::sync::mpsc::channel();
2763 let command_worker = std::thread::spawn(move || {
2764 command_sender
2765 .send(worker_session.invoke_command("wait", ""))
2766 .unwrap();
2767 });
2768
2769 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
2770 while session.transport.starting.lock().unwrap().is_none()
2771 && std::time::Instant::now() < deadline
2772 {
2773 std::thread::sleep(std::time::Duration::from_millis(10));
2774 }
2775 assert!(
2776 session.transport.starting.lock().unwrap().is_some(),
2777 "lazy startup did not publish its in-flight transport"
2778 );
2779
2780 let shutdown_session = session.clone();
2781 let (shutdown_sender, shutdown_receiver) = std::sync::mpsc::channel();
2782 let shutdown_worker = std::thread::spawn(move || {
2783 shutdown_session.shutdown();
2784 shutdown_sender.send(()).unwrap();
2785 });
2786 shutdown_receiver
2787 .recv_timeout(std::time::Duration::from_secs(2))
2788 .expect("shutdown should not wait for a blocked initialization request");
2789 let result = command_receiver
2790 .recv_timeout(std::time::Duration::from_secs(2))
2791 .expect("shutdown should wake lazy initialization");
2792 assert!(
2793 result.is_err(),
2794 "stopped initialization unexpectedly succeeded"
2795 );
2796 command_worker.join().unwrap();
2797 shutdown_worker.join().unwrap();
2798 }
2799
2800 #[test]
2801 fn shutting_down_during_node_initialization_kills_host_before_init_response() {
2802 let temp = tempfile::tempdir().unwrap();
2803 let path = temp.path().join("shutdown-before-init.js");
2804 std::fs::write(
2805 &path,
2806 r#"export default (pi) => {
2807 pi.on('before_agent_start', async (_event, ctx) => {
2808 // Discovery runs in the one-shot host. Only the persistent
2809 // host deliberately blocks before publishing id:0.
2810 if (ctx.hasUI && process.env.RPI_JS_EXTENSION_ONESHOT !== '1') {
2811 await new Promise(resolve => setTimeout(resolve, 30000));
2812 }
2813 });
2814 pi.registerCommand('wait', async () => ({ text: 'late' }));
2815 };"#,
2816 )
2817 .unwrap();
2818
2819 let session = JsExtensionSession::load_with_context(
2820 &[path],
2821 false,
2822 serde_json::json!({"mode":"tui", "hasUI":true}),
2823 )
2824 .unwrap()
2825 .unwrap();
2826 let worker_session = session.clone();
2827 let (command_sender, command_receiver) = std::sync::mpsc::channel();
2828 let command_worker = std::thread::spawn(move || {
2829 command_sender
2830 .send(worker_session.invoke_command("wait", ""))
2831 .unwrap();
2832 });
2833
2834 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
2835 while session.transport.starting.lock().unwrap().is_none()
2836 && std::time::Instant::now() < deadline
2837 {
2838 std::thread::sleep(std::time::Duration::from_millis(10));
2839 }
2840 assert!(
2841 session.transport.starting.lock().unwrap().is_some(),
2842 "lazy startup did not publish its transport before init"
2843 );
2844
2845 let started = std::time::Instant::now();
2846 session.shutdown();
2847 let result = command_receiver
2848 .recv_timeout(std::time::Duration::from_secs(2))
2849 .expect("shutdown should wake a command blocked in Node init");
2850 assert!(
2851 result.is_err(),
2852 "stopped initialization unexpectedly succeeded"
2853 );
2854 assert!(
2855 started.elapsed() < std::time::Duration::from_secs(2),
2856 "shutdown waited for the blocked init hook"
2857 );
2858 command_worker.join().unwrap();
2859 }
2860
2861 #[test]
2862 fn cancelling_prompt_preparation_kills_stuck_hook_and_allows_restart() {
2863 let temp = tempfile::tempdir().unwrap();
2864 let marker = temp.path().join("blocked-once");
2865 let marker_literal = serde_json::to_string(&marker.to_string_lossy()).unwrap();
2866 let path = temp.path().join("cancel-prompt-preparation.js");
2867 std::fs::write(
2868 &path,
2869 format!(
2870 r#"import fs from 'node:fs';
2871 export default (pi) => {{
2872 pi.registerCommand('ping', async () => ({{ text: 'pong' }}));
2873 pi.on('before_agent_start', async () => {{
2874 if (process.env.RPI_JS_EXTENSION_ONESHOT !== '1'
2875 && !fs.existsSync({marker})) {{
2876 fs.writeFileSync({marker}, 'blocked');
2877 await new Promise(() => {{}});
2878 }}
2879 }});
2880 }};"#,
2881 marker = marker_literal,
2882 ),
2883 )
2884 .unwrap();
2885
2886 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2887 let cancellation = CancellationToken::new();
2888 let worker_session = session.clone();
2889 let worker_cancellation = cancellation.clone();
2890 let (sender, receiver) = std::sync::mpsc::channel();
2891 let worker = std::thread::spawn(move || {
2892 sender
2893 .send(worker_session.prepare_for_prompt_with_cancellation(&worker_cancellation))
2894 .unwrap();
2895 });
2896
2897 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
2898 while !marker.is_file() && std::time::Instant::now() < deadline {
2899 std::thread::sleep(std::time::Duration::from_millis(5));
2900 }
2901 assert!(marker.is_file(), "persistent lifecycle hook did not block");
2902
2903 cancellation.cancel();
2904 session.cancel_prompt_preparation();
2905 let result = receiver
2906 .recv_timeout(std::time::Duration::from_secs(2))
2907 .expect("cancellation should wake a stuck prompt preparation");
2908 assert!(
2909 result.is_err(),
2910 "cancelled preparation unexpectedly succeeded"
2911 );
2912 worker.join().unwrap();
2913 assert!(session.transport.transport.lock().unwrap().is_none());
2914 assert!(session.transport.starting.lock().unwrap().is_none());
2915
2916 session.prepare_for_prompt().unwrap();
2919 let value = session.invoke_command("ping", "").unwrap();
2920 assert_eq!(value["result"]["text"], "pong");
2921 }
2922
2923 #[test]
2924 fn node_transport_cancellation_reaches_tool_abort_signal() {
2925 let temp = tempfile::tempdir().unwrap();
2926 let path = temp.path().join("cancel.js");
2927 std::fs::write(
2928 &path,
2929 r#"export default (pi) => pi.registerTool({
2930 name: 'wait',
2931 description: 'wait for cancellation',
2932 parameters: { type: 'object', properties: {} },
2933 execute: (_id, _args, signal) => new Promise(resolve => {
2934 signal.addEventListener('abort', () => resolve({
2935 content: [{ type: 'text', text: 'cancelled' }],
2936 details: {}
2937 }), { once: true });
2938 })
2939 });"#,
2940 )
2941 .unwrap();
2942 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2943 let pending = session
2944 .transport
2945 .begin_request(
2946 "invoke_tool",
2947 serde_json::json!({"tool": "wait", "toolCallId": "test", "args": {}}),
2948 )
2949 .unwrap();
2950 let request_id = pending.id();
2951 std::thread::sleep(std::time::Duration::from_millis(30));
2952 session.transport.cancel(request_id).unwrap();
2953 let result = pending.wait().unwrap();
2954 assert_eq!(result["content"][0]["text"], "cancelled");
2955 }
2956
2957 #[test]
2958 fn unsupported_ui_capabilities_fail_with_explicit_error() {
2959 let temp = tempfile::tempdir().unwrap();
2960 let path = temp.path().join("ui.js");
2961 std::fs::write(
2962 &path,
2963 "export default (pi) => pi.registerCommand('ui', async (_args, ctx) => { await ctx.ui.custom(() => null); });",
2964 )
2965 .unwrap();
2966 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2967 let error = session.invoke_command("ui", "").unwrap_err();
2968 assert!(error.contains("unsupported capability: ui.custom"));
2969 }
2970
2971 #[test]
2972 fn custom_ui_roundtrips_open_render_and_done() {
2973 let temp = tempfile::tempdir().unwrap();
2974 let path = temp.path().join("custom.js");
2975 std::fs::write(
2976 &path,
2977 r#"export default (pi) => pi.registerCommand('custom', async (_args, ctx) => {
2978 return await ctx.ui.custom((parent, theme, keybindings, done) => {
2979 parent.terminal.write(theme.fg('muted', 'frame'));
2980 done({ ok: true });
2981 return { render() { return ['frame']; }, handleInput() { return true; } };
2982 }, { overlay: true });
2983 });"#,
2984 )
2985 .unwrap();
2986 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
2987 let actions = Arc::new(Mutex::new(Vec::<String>::new()));
2988 let observed = actions.clone();
2989 session
2990 .set_runtime_handler(Arc::new(move |action, args| {
2991 observed.lock().unwrap().push(action.to_string());
2992 match action {
2993 "ui.custom.open" | "ui.custom.close" => Ok(serde_json::json!(true)),
2994 "ui.custom.write" => {
2995 let data = args["data"].as_str().unwrap_or_default();
2996 assert!(data.ends_with("frame\r\n") || data.starts_with("frame"));
2997 Ok(serde_json::json!(true))
2998 }
2999 _ => Err(format!("unsupported capability: {action}")),
3000 }
3001 }))
3002 .unwrap();
3003 let value = session.invoke_command("custom", "").unwrap();
3004 assert_eq!(value["result"]["ok"], true);
3005 let actions = actions.lock().unwrap();
3006 assert!(actions.iter().any(|action| action == "ui.custom.open"));
3007 assert!(actions.iter().any(|action| action == "ui.custom.write"));
3008 assert!(actions.iter().any(|action| action == "ui.custom.close"));
3009 }
3010
3011 #[test]
3012 fn custom_ui_forwards_overlay_options_handle_and_terminal_size() {
3013 let temp = tempfile::tempdir().unwrap();
3014 let path = temp.path().join("custom-overlay.js");
3015 std::fs::write(
3016 &path,
3017 r#"export default (pi) => pi.registerCommand('custom', async (_args, ctx) => {
3018 let finish;
3019 const result = ctx.ui.custom((parent, _theme, _keys, done) => {
3020 if (parent.terminal.columns !== 90 || parent.terminal.rows !== 20) throw new Error('wrong terminal size');
3021 finish = done;
3022 return { render() { return ['overlay-frame']; }, invalidate() {}, dispose() {} };
3023 }, {
3024 overlay: true,
3025 overlayOptions: { anchor: 'bottom-center', width: '100%', maxHeight: '100%', margin: { left: 0, right: 0, bottom: 0 } },
3026 onHandle(handle) {
3027 if (!handle.isFocused() || handle.isHidden()) throw new Error('invalid overlay handle');
3028 handle.setHidden(true);
3029 if (!handle.isHidden()) throw new Error('setHidden(true) was ignored');
3030 handle.setHidden(false);
3031 if (handle.isHidden()) throw new Error('setHidden(false) was ignored');
3032 finish({ ok: true });
3033 }
3034 });
3035 return await result;
3036 });"#,
3037 )
3038 .unwrap();
3039 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
3040 let actions = Arc::new(Mutex::new(Vec::<(String, serde_json::Value)>::new()));
3041 let observed = actions.clone();
3042 session
3043 .set_runtime_handler(Arc::new(move |action, args| {
3044 observed
3045 .lock()
3046 .unwrap()
3047 .push((action.to_string(), args.clone()));
3048 match action {
3049 "ui.custom.open" => Ok(serde_json::json!({ "columns": 90, "rows": 20 })),
3050 "ui.custom.handle" | "ui.custom.close" | "ui.custom.invalidate" => {
3051 Ok(serde_json::json!(true))
3052 }
3053 "ui.custom.write" => Ok(serde_json::json!(true)),
3054 _ => Err(format!("unsupported capability: {action}")),
3055 }
3056 }))
3057 .unwrap();
3058 let value = session.invoke_command("custom", "").unwrap();
3059 assert_eq!(value["result"]["ok"], true);
3060 let actions = actions.lock().unwrap();
3061 let open = actions
3062 .iter()
3063 .find(|(action, _)| action == "ui.custom.open")
3064 .expect("custom open action");
3065 assert_eq!(open.1["options"]["overlay"], true);
3066 assert_eq!(
3067 open.1["options"]["overlayOptions"]["anchor"],
3068 "bottom-center"
3069 );
3070 assert!(actions
3071 .iter()
3072 .any(|(action, _)| action == "ui.custom.handle"));
3073 assert!(actions
3074 .iter()
3075 .any(|(action, args)| { action == "ui.custom.handle" && args["hidden"] == true }));
3076 assert!(actions
3077 .iter()
3078 .any(|(action, args)| { action == "ui.custom.handle" && args["hidden"] == false }));
3079 }
3080
3081 #[test]
3082 fn custom_ui_terminal_input_listener_can_consume_before_component() {
3083 let temp = tempfile::tempdir().unwrap();
3084 let path = temp.path().join("custom-input.js");
3085 std::fs::write(
3086 &path,
3087 r#"export default (pi) => pi.registerCommand('custom', async (_args, ctx) => {
3088 let finish;
3089 ctx.ui.onTerminalInput((data) => {
3090 if (data !== 'x') return;
3091 finish({ consumed: true });
3092 return { consume: true };
3093 });
3094 return await ctx.ui.custom((_parent, _theme, _keys, done) => {
3095 finish = done;
3096 return { handleInput() { throw new Error('component received consumed input'); } };
3097 });
3098 });"#,
3099 )
3100 .unwrap();
3101 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
3102 let tui = Arc::new(rpi_tui::TuiAltScreen::new(
3103 Box::new(rpi_tui::ProcessTerminal::new()),
3104 true,
3105 None,
3106 ));
3107 session.install_ui_runtime(tui).unwrap();
3108
3109 let worker_session = session.clone();
3110 let (sender, receiver) = std::sync::mpsc::channel();
3111 std::thread::spawn(move || {
3112 sender
3113 .send(worker_session.invoke_command("custom", ""))
3114 .unwrap();
3115 });
3116 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
3117 while !session.custom_active() && std::time::Instant::now() < deadline {
3118 std::thread::sleep(std::time::Duration::from_millis(10));
3119 }
3120 assert!(session.custom_active(), "custom UI did not open");
3121 session.send_custom_input("x").unwrap();
3122 let value = receiver
3123 .recv_timeout(std::time::Duration::from_secs(5))
3124 .unwrap()
3125 .unwrap();
3126 assert_eq!(value["result"]["consumed"], true);
3127 }
3128
3129 #[test]
3130 fn custom_ui_terminal_input_listener_can_swallow_with_empty_data() {
3131 let temp = tempfile::tempdir().unwrap();
3132 let path = temp.path().join("custom-empty-input.js");
3133 std::fs::write(
3134 &path,
3135 r#"export default (pi) => pi.registerCommand('custom', async (_args, ctx) => {
3136 let finish;
3137 let componentCalls = 0;
3138 ctx.ui.onTerminalInput((data) => {
3139 if (data !== 'empty') return;
3140 // Let the current input dispatch finish before resolving
3141 // the command. Without the host's empty-data short-circuit
3142 // the component is called before this timer and the count
3143 // becomes 1.
3144 setTimeout(() => finish({ componentCalls }), 0);
3145 return { data: '' };
3146 });
3147 return await ctx.ui.custom((_parent, _theme, _keys, done) => {
3148 finish = done;
3149 return { handleInput() { componentCalls += 1; } };
3150 });
3151 });"#,
3152 )
3153 .unwrap();
3154 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
3155 let tui = Arc::new(rpi_tui::TuiAltScreen::new(
3156 Box::new(rpi_tui::ProcessTerminal::new()),
3157 true,
3158 None,
3159 ));
3160 session.install_ui_runtime(tui).unwrap();
3161
3162 let worker_session = session.clone();
3163 let (sender, receiver) = std::sync::mpsc::channel();
3164 std::thread::spawn(move || {
3165 sender
3166 .send(worker_session.invoke_command("custom", ""))
3167 .unwrap();
3168 });
3169 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
3170 while !session.custom_active() && std::time::Instant::now() < deadline {
3171 std::thread::sleep(std::time::Duration::from_millis(10));
3172 }
3173 assert!(session.custom_active(), "custom UI did not open");
3174
3175 session.send_custom_input("empty").unwrap();
3176 let value = receiver
3177 .recv_timeout(std::time::Duration::from_secs(2))
3178 .unwrap()
3179 .unwrap();
3180 assert_eq!(value["result"]["componentCalls"], 0);
3181 }
3182
3183 #[test]
3184 fn hidden_custom_ui_releases_unconsumed_input_but_keeps_listener() {
3185 let temp = tempfile::tempdir().unwrap();
3186 let path = temp.path().join("custom-hidden-input.js");
3187 std::fs::write(
3188 &path,
3189 r#"export default (pi) => pi.registerCommand('custom', async (_args, ctx) => {
3190 let finish;
3191 let reopen;
3192 let componentCalls = 0;
3193 ctx.ui.onTerminalInput((data) => {
3194 if (data === 'reopen') {
3195 reopen?.setHidden(false);
3196 return { consume: true };
3197 }
3198 if (data !== 'finish') return;
3199 finish({ componentCalls });
3200 return { consume: true };
3201 });
3202 return await ctx.ui.custom((_parent, _theme, _keys, done) => {
3203 finish = done;
3204 return { handleInput() { componentCalls += 1; } };
3205 }, { onHandle(handle) { reopen = handle; handle.setHidden(true); } });
3206 });"#,
3207 )
3208 .unwrap();
3209 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
3210 let tui = Arc::new(rpi_tui::TuiAltScreen::new(
3211 Box::new(rpi_tui::ProcessTerminal::new()),
3212 true,
3213 None,
3214 ));
3215 session.install_ui_runtime(tui).unwrap();
3216
3217 let worker_session = session.clone();
3218 let (sender, receiver) = std::sync::mpsc::channel();
3219 std::thread::spawn(move || {
3220 sender
3221 .send(worker_session.invoke_command("custom", ""))
3222 .unwrap();
3223 });
3224 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
3225 while (session.custom_accepts_input() || !session.custom_active())
3226 && std::time::Instant::now() < deadline
3227 {
3228 std::thread::sleep(std::time::Duration::from_millis(10));
3229 }
3230 assert!(session.custom_active(), "custom UI did not open");
3231 assert!(
3232 !session.custom_accepts_input(),
3233 "hidden UI kept keyboard focus"
3234 );
3235
3236 assert!(!session.send_custom_input_with_consumed("ordinary").unwrap());
3239 assert!(session.send_custom_input_with_consumed("reopen").unwrap());
3240 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
3241 while !session.custom_accepts_input() && std::time::Instant::now() < deadline {
3242 std::thread::sleep(std::time::Duration::from_millis(10));
3243 }
3244 assert!(
3245 session.custom_accepts_input(),
3246 "reopen shortcut did not restore focus"
3247 );
3248 assert!(session.send_custom_input_with_consumed("finish").unwrap());
3251 let value = receiver
3252 .recv_timeout(std::time::Duration::from_secs(2))
3253 .unwrap()
3254 .unwrap();
3255 assert_eq!(value["result"]["componentCalls"], 0);
3256 }
3257
3258 #[test]
3259 fn custom_ui_closes_when_command_is_cancelled() {
3260 let temp = tempfile::tempdir().unwrap();
3261 let path = temp.path().join("custom-cancel.js");
3262 std::fs::write(
3263 &path,
3264 r#"export default (pi) => pi.registerCommand('custom', async (_args, ctx) => {
3265 return await ctx.ui.custom((_parent, _theme, _keys, _done) => ({
3266 handleInput() {},
3267 dispose() {},
3268 }));
3269 });"#,
3270 )
3271 .unwrap();
3272 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
3273 let tui = Arc::new(rpi_tui::TuiAltScreen::new(
3274 Box::new(rpi_tui::ProcessTerminal::new()),
3275 true,
3276 None,
3277 ));
3278 session.install_ui_runtime(tui).unwrap();
3279
3280 let pending = session
3281 .transport
3282 .begin_request(
3283 "invoke_command",
3284 serde_json::json!({"command":"custom", "args":"", "context":{}}),
3285 )
3286 .unwrap();
3287 let request_id = pending.id();
3288 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
3289 while !session.custom_active() && std::time::Instant::now() < deadline {
3290 std::thread::sleep(std::time::Duration::from_millis(10));
3291 }
3292 assert!(session.custom_active(), "custom UI did not open");
3293
3294 session.transport.cancel(request_id).unwrap();
3295 let value = pending
3296 .wait()
3297 .expect("cancelled custom command should resolve");
3298 assert!(value.get("result").is_some());
3299 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
3300 while session.custom_active() && std::time::Instant::now() < deadline {
3301 std::thread::sleep(std::time::Duration::from_millis(10));
3302 }
3303 assert!(
3304 !session.custom_active(),
3305 "cancelled custom UI remained active"
3306 );
3307 }
3308
3309 #[test]
3310 fn runtime_handler_added_during_startup_reaches_starting_host() {
3311 let temp = tempfile::tempdir().unwrap();
3312 let ready = temp.path().join("persistent-factory-ready");
3313 let release = temp.path().join("release-persistent-factory");
3314 let path = temp.path().join("startup-handler-race.js");
3315 let ready_literal = serde_json::to_string(&ready.to_string_lossy()).unwrap();
3316 let release_literal = serde_json::to_string(&release.to_string_lossy()).unwrap();
3317 std::fs::write(
3318 &path,
3319 format!(
3320 r#"import fs from 'node:fs';
3321 const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3322 export default async (pi) => {{
3323 if (process.env.RPI_JS_EXTENSION_ONESHOT === '1') {{
3324 pi.registerCommand('late', async () => ({{ text: 'discovery' }}));
3325 return;
3326 }}
3327 fs.writeFileSync({ready}, 'ready');
3328 while (!fs.existsSync({release})) await sleep(5);
3329 const value = await pi.runtimeRequest('late.echo', {{ value: 'handled' }});
3330 pi.registerCommand('late', async () => ({{ text: String(value) }}));
3331 }};"#,
3332 ready = ready_literal,
3333 release = release_literal,
3334 ),
3335 )
3336 .unwrap();
3337
3338 let session = JsExtensionSession::load(&[path], false).unwrap().unwrap();
3339 let worker_session = session.clone();
3340 let (sender, receiver) = std::sync::mpsc::channel();
3341 std::thread::spawn(move || {
3342 sender
3343 .send(worker_session.invoke_command("late", ""))
3344 .unwrap();
3345 });
3346
3347 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
3348 while !ready.is_file() && std::time::Instant::now() < deadline {
3349 std::thread::sleep(std::time::Duration::from_millis(5));
3350 }
3351 assert!(ready.is_file(), "persistent factory did not reach startup");
3352 while session.transport.starting.lock().unwrap().is_none()
3353 && std::time::Instant::now() < deadline
3354 {
3355 std::thread::sleep(std::time::Duration::from_millis(5));
3356 }
3357 assert!(
3358 session.transport.starting.lock().unwrap().is_some(),
3359 "startup transport was not published"
3360 );
3361
3362 session
3363 .set_runtime_handler(Arc::new(|action, args| {
3364 if action == "late.echo" {
3365 Ok(args.get("value").cloned().unwrap_or_default())
3366 } else {
3367 Err(format!("unsupported capability: {action}"))
3368 }
3369 }))
3370 .unwrap();
3371 std::fs::write(&release, "release").unwrap();
3372
3373 let result = receiver
3374 .recv_timeout(std::time::Duration::from_secs(2))
3375 .expect("startup command did not finish");
3376 assert_eq!(result.unwrap()["result"]["text"], "handled");
3377 }
3378}