Skip to main content

rpi_cli/
js_extensions.rs

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