Skip to main content

pi/
extension_dispatcher.rs

1//! Hostcall dispatcher for JS extensions.
2//!
3//! This module introduces the core `ExtensionDispatcher` abstraction used to route
4//! hostcall requests (tools, HTTP, session, UI, etc.) from the JS runtime to
5//! Rust implementations.
6
7use std::cell::RefCell;
8use std::collections::BTreeSet;
9use std::collections::VecDeque;
10use std::path::PathBuf;
11use std::rc::Rc;
12use std::sync::Arc;
13use std::thread;
14use std::time::{Duration, Instant};
15
16use asupersync::Cx;
17use asupersync::time::{sleep, wall_now};
18use async_trait::async_trait;
19use serde_json::Value;
20use sha2::Digest as _;
21
22use crate::connectors::{Connector, http::HttpConnector};
23use crate::error::Result;
24use crate::extensions::EXTENSION_EVENT_TIMEOUT_MS;
25use crate::extensions::{
26    DangerousCommandClass, ExecMediationResult, ExtensionBody, ExtensionMessage, ExtensionPolicy,
27    ExtensionSession, ExtensionUiRequest, ExtensionUiResponse, HostCallError, HostCallErrorCode,
28    HostCallPayload, HostResultPayload, HostStreamChunk, PROTOCOL_VERSION, PolicyCheck,
29    PolicyDecision, PolicyProfile, PolicySnapshot, classify_ui_hostcall_error,
30    evaluate_exec_mediation, hash_canonical_json, required_capability_for_host_call_static,
31    ui_response_value_for_op, validate_host_call,
32};
33use crate::extensions_js::{HostcallKind, HostcallRequest, PiJsRuntime, js_to_json, json_to_js};
34use crate::hostcall_amac::{AmacBatchExecutor, AmacBatchExecutorConfig};
35use crate::hostcall_io_uring_lane::{
36    HostcallCapabilityClass, HostcallDispatchLane, HostcallIoHint, IoUringLaneDecisionInput,
37    IoUringLanePolicyConfig, decide_io_uring_lane,
38};
39use crate::scheduler::{Clock as SchedulerClock, HostcallOutcome, WallClock};
40use crate::tools::ToolRegistry;
41
42struct CancelGuard(Arc<std::sync::atomic::AtomicBool>);
43impl Drop for CancelGuard {
44    fn drop(&mut self) {
45        self.0.store(true, std::sync::atomic::Ordering::SeqCst);
46    }
47}
48
49fn extension_wait_now() -> asupersync::types::Time {
50    Cx::current()
51        .and_then(|cx| cx.timer_driver())
52        .map_or_else(wall_now, |driver| driver.now())
53}
54
55fn extension_wait_sleep(duration: Duration) -> asupersync::time::Sleep {
56    sleep(extension_wait_now(), duration)
57}
58
59/// Coordinates hostcall dispatch between the JS extension runtime and Rust handlers.
60pub struct ExtensionDispatcher<C: SchedulerClock = WallClock> {
61    /// Runtime bridge used by the dispatcher.
62    runtime: Rc<dyn ExtensionDispatcherRuntime<C>>,
63    /// Registry of available tools (built-in + extension-registered).
64    tool_registry: Arc<ToolRegistry>,
65    /// HTTP connector for pi.http() calls.
66    http_connector: Arc<HttpConnector>,
67    /// Session access for pi.session() calls.
68    session: Arc<dyn ExtensionSession + Send + Sync>,
69    /// UI handler for pi.ui() calls.
70    ui_handler: Arc<dyn ExtensionUiHandler + Send + Sync>,
71    /// Current working directory for relative path resolution.
72    cwd: PathBuf,
73    /// Capability policy governing which hostcalls are allowed.
74    policy: ExtensionPolicy,
75    /// Precomputed O(1) capability decision table.
76    snapshot: PolicySnapshot,
77    /// Deterministic policy snapshot version hash for provenance/telemetry.
78    snapshot_version: String,
79    /// Configuration for sampled shadow dual execution.
80    dual_exec_config: DualExecOracleConfig,
81    /// Runtime state for sampled dual execution and rollback guards.
82    dual_exec_state: RefCell<DualExecOracleState>,
83    /// Decision-only io_uring lane policy for IO-dominant hostcalls.
84    io_uring_lane_config: IoUringLanePolicyConfig,
85    /// Kill switch forcing compatibility lane regardless of policy input.
86    io_uring_force_compat: bool,
87    /// Adaptive regime detector for hostcall workload shifts.
88    regime_detector: RefCell<RegimeShiftDetector>,
89    /// AMAC batch executor for interleaved hostcall dispatch.
90    amac_executor: RefCell<AmacBatchExecutor>,
91}
92
93/// Runtime bridge trait so dispatcher logic is not hardwired to a concrete runtime type.
94pub trait ExtensionDispatcherRuntime<C: SchedulerClock>: 'static {
95    fn as_js_runtime(&self) -> &PiJsRuntime<C>;
96}
97
98impl<C: SchedulerClock + 'static> ExtensionDispatcherRuntime<C> for PiJsRuntime<C> {
99    #[allow(clippy::use_self)]
100    fn as_js_runtime(&self) -> &PiJsRuntime<C> {
101        self
102    }
103}
104
105fn protocol_hostcall_op(params: &Value) -> Option<&str> {
106    params
107        .get("op")
108        .or_else(|| params.get("method"))
109        .or_else(|| params.get("name"))
110        .and_then(Value::as_str)
111        .map(str::trim)
112        .filter(|value| !value.is_empty())
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116enum ProtocolHostcallMethod {
117    Tool,
118    Exec,
119    Http,
120    Session,
121    Ui,
122    Events,
123    Log,
124}
125
126fn parse_protocol_hostcall_method(method: &str) -> Option<ProtocolHostcallMethod> {
127    let method = method.trim();
128    if method.is_empty() {
129        return None;
130    }
131
132    if method.eq_ignore_ascii_case("tool") {
133        Some(ProtocolHostcallMethod::Tool)
134    } else if method.eq_ignore_ascii_case("exec") {
135        Some(ProtocolHostcallMethod::Exec)
136    } else if method.eq_ignore_ascii_case("http") {
137        Some(ProtocolHostcallMethod::Http)
138    } else if method.eq_ignore_ascii_case("session") {
139        Some(ProtocolHostcallMethod::Session)
140    } else if method.eq_ignore_ascii_case("ui") {
141        Some(ProtocolHostcallMethod::Ui)
142    } else if method.eq_ignore_ascii_case("events") {
143        Some(ProtocolHostcallMethod::Events)
144    } else if method.eq_ignore_ascii_case("log") {
145        Some(ProtocolHostcallMethod::Log)
146    } else {
147        None
148    }
149}
150
151fn protocol_normalize_output(value: Value) -> Value {
152    if value.is_object() {
153        value
154    } else {
155        serde_json::json!({ "value": value })
156    }
157}
158
159fn policy_snapshot_version(policy: &ExtensionPolicy) -> String {
160    let mut hasher = sha2::Sha256::new();
161    match serde_json::to_value(policy) {
162        Ok(value) => hash_canonical_json(&value, &mut hasher),
163        Err(err) => hasher.update(err.to_string().as_bytes()),
164    }
165    format!("{:x}", hasher.finalize())
166}
167
168fn policy_lookup_path(capability: &str) -> &'static str {
169    let capability = capability.trim();
170    if capability.eq_ignore_ascii_case("read")
171        || capability.eq_ignore_ascii_case("write")
172        || capability.eq_ignore_ascii_case("exec")
173        || capability.eq_ignore_ascii_case("env")
174        || capability.eq_ignore_ascii_case("http")
175        || capability.eq_ignore_ascii_case("session")
176        || capability.eq_ignore_ascii_case("events")
177        || capability.eq_ignore_ascii_case("ui")
178        || capability.eq_ignore_ascii_case("log")
179        || capability.eq_ignore_ascii_case("tool")
180    {
181        "policy_snapshot_table"
182    } else {
183        "policy_snapshot_fallback"
184    }
185}
186
187fn protocol_error_code(code: &str) -> HostCallErrorCode {
188    let code = code.trim();
189    if code.eq_ignore_ascii_case("timeout") {
190        HostCallErrorCode::Timeout
191    } else if code.eq_ignore_ascii_case("denied") {
192        HostCallErrorCode::Denied
193    } else if code.eq_ignore_ascii_case("io") || code.eq_ignore_ascii_case("tool_error") {
194        HostCallErrorCode::Io
195    } else if code.eq_ignore_ascii_case("invalid_request") {
196        HostCallErrorCode::InvalidRequest
197    } else {
198        HostCallErrorCode::Internal
199    }
200}
201
202fn protocol_error_fallback_reason(method: &str, code: &str) -> &'static str {
203    let code = code.trim();
204    if code.eq_ignore_ascii_case("denied") {
205        "policy_denied"
206    } else if code.eq_ignore_ascii_case("timeout") {
207        "handler_timeout"
208    } else if code.eq_ignore_ascii_case("io") || code.eq_ignore_ascii_case("tool_error") {
209        "handler_error"
210    } else if code.eq_ignore_ascii_case("invalid_request") {
211        if parse_protocol_hostcall_method(method).is_some() {
212            "schema_validation_failed"
213        } else {
214            "unsupported_method_fallback"
215        }
216    } else {
217        "runtime_internal_error"
218    }
219}
220
221fn protocol_error_details(payload: &HostCallPayload, code: &str, message: &str) -> Value {
222    let observed_param_keys = payload
223        .params
224        .as_object()
225        .map(|object| {
226            let mut keys = object.keys().cloned().collect::<Vec<_>>();
227            keys.sort();
228            keys
229        })
230        .unwrap_or_default();
231
232    serde_json::json!({
233        "dispatcherDecisionTrace": {
234            "selectedRuntime": "rust-extension-dispatcher",
235            "schemaPath": "ExtensionBody::HostCall/HostCallPayload",
236            "schemaVersion": PROTOCOL_VERSION,
237            "method": payload.method,
238            "capability": payload.capability,
239            "fallbackReason": protocol_error_fallback_reason(&payload.method, code),
240        },
241        "schemaDiff": {
242            "observedParamKeys": observed_param_keys,
243        },
244        "extensionInput": {
245            "callId": payload.call_id,
246            "capability": payload.capability,
247            "method": payload.method,
248            "params": payload.params,
249        },
250        "extensionOutput": {
251            "code": code,
252            "message": message,
253        },
254    })
255}
256
257fn hostcall_outcome_to_protocol_result(
258    call_id: &str,
259    outcome: HostcallOutcome,
260) -> HostResultPayload {
261    match outcome {
262        HostcallOutcome::Success(output) => HostResultPayload {
263            call_id: call_id.to_string(),
264            output: protocol_normalize_output(output),
265            is_error: false,
266            error: None,
267            chunk: None,
268        },
269        HostcallOutcome::StreamChunk {
270            sequence,
271            chunk,
272            is_final,
273        } => HostResultPayload {
274            call_id: call_id.to_string(),
275            output: serde_json::json!({
276                "sequence": sequence,
277                "chunk": chunk,
278                "isFinal": is_final,
279            }),
280            is_error: false,
281            error: None,
282            chunk: Some(HostStreamChunk {
283                index: sequence,
284                is_last: is_final,
285                backpressure: None,
286            }),
287        },
288        HostcallOutcome::Error { code, message } => HostResultPayload {
289            call_id: call_id.to_string(),
290            output: serde_json::json!({}),
291            is_error: true,
292            error: Some(HostCallError {
293                code: protocol_error_code(&code),
294                message,
295                details: None,
296                retryable: None,
297            }),
298            chunk: None,
299        },
300    }
301}
302
303fn hostcall_outcome_to_protocol_result_with_trace(
304    payload: &HostCallPayload,
305    outcome: HostcallOutcome,
306) -> HostResultPayload {
307    match outcome {
308        HostcallOutcome::Success(output) => HostResultPayload {
309            call_id: payload.call_id.clone(),
310            output: protocol_normalize_output(output),
311            is_error: false,
312            error: None,
313            chunk: None,
314        },
315        HostcallOutcome::StreamChunk {
316            sequence,
317            chunk,
318            is_final,
319        } => HostResultPayload {
320            call_id: payload.call_id.clone(),
321            output: serde_json::json!({
322                "sequence": sequence,
323                "chunk": chunk,
324                "isFinal": is_final,
325            }),
326            is_error: false,
327            error: None,
328            chunk: Some(HostStreamChunk {
329                index: sequence,
330                is_last: is_final,
331                backpressure: None,
332            }),
333        },
334        HostcallOutcome::Error { code, message } => {
335            let details = Some(protocol_error_details(payload, &code, &message));
336            HostResultPayload {
337                call_id: payload.call_id.clone(),
338                output: serde_json::json!({}),
339                is_error: true,
340                error: Some(HostCallError {
341                    code: protocol_error_code(&code),
342                    message,
343                    details,
344                    retryable: None,
345                }),
346                chunk: None,
347            }
348        }
349    }
350}
351
352const DUAL_EXEC_SAMPLE_MODULUS_PPM: u32 = 1_000_000;
353const DUAL_EXEC_DEFAULT_SAMPLE_PPM: u32 = 25_000;
354const DUAL_EXEC_DEFAULT_DIVERGENCE_WINDOW: usize = 64;
355const DUAL_EXEC_DEFAULT_DIVERGENCE_BUDGET: usize = 3;
356const DUAL_EXEC_DEFAULT_ROLLBACK_REQUESTS: usize = 128;
357const DUAL_EXEC_DEFAULT_OVERHEAD_BUDGET_US: u64 = 1_500;
358const DUAL_EXEC_DEFAULT_OVERHEAD_BACKOFF_REQUESTS: usize = 32;
359
360#[derive(Debug, Clone, Copy)]
361struct DualExecOracleConfig {
362    sample_ppm: u32,
363    divergence_window: usize,
364    divergence_budget: usize,
365    rollback_requests: usize,
366    overhead_budget_us: u64,
367    overhead_backoff_requests: usize,
368}
369
370impl Default for DualExecOracleConfig {
371    fn default() -> Self {
372        Self::from_env()
373    }
374}
375
376impl DualExecOracleConfig {
377    fn from_env() -> Self {
378        let sample_ppm = std::env::var("PI_EXT_DUAL_EXEC_SAMPLE_PPM")
379            .ok()
380            .and_then(|raw| raw.trim().parse::<u32>().ok())
381            .unwrap_or(DUAL_EXEC_DEFAULT_SAMPLE_PPM)
382            .min(DUAL_EXEC_SAMPLE_MODULUS_PPM);
383        let divergence_window = std::env::var("PI_EXT_DUAL_EXEC_DIVERGENCE_WINDOW")
384            .ok()
385            .and_then(|raw| raw.trim().parse::<usize>().ok())
386            .unwrap_or(DUAL_EXEC_DEFAULT_DIVERGENCE_WINDOW)
387            .max(1);
388        let divergence_budget = std::env::var("PI_EXT_DUAL_EXEC_DIVERGENCE_BUDGET")
389            .ok()
390            .and_then(|raw| raw.trim().parse::<usize>().ok())
391            .unwrap_or(DUAL_EXEC_DEFAULT_DIVERGENCE_BUDGET)
392            .max(1);
393        let rollback_requests = std::env::var("PI_EXT_DUAL_EXEC_ROLLBACK_REQUESTS")
394            .ok()
395            .and_then(|raw| raw.trim().parse::<usize>().ok())
396            .unwrap_or(DUAL_EXEC_DEFAULT_ROLLBACK_REQUESTS)
397            .max(1);
398        let overhead_budget_us = std::env::var("PI_EXT_DUAL_EXEC_OVERHEAD_BUDGET_US")
399            .ok()
400            .and_then(|raw| raw.trim().parse::<u64>().ok())
401            .unwrap_or(DUAL_EXEC_DEFAULT_OVERHEAD_BUDGET_US)
402            .max(1);
403        let overhead_backoff_requests = std::env::var("PI_EXT_DUAL_EXEC_OVERHEAD_BACKOFF_REQUESTS")
404            .ok()
405            .and_then(|raw| raw.trim().parse::<usize>().ok())
406            .unwrap_or(DUAL_EXEC_DEFAULT_OVERHEAD_BACKOFF_REQUESTS)
407            .max(1);
408
409        Self {
410            sample_ppm,
411            divergence_window,
412            divergence_budget,
413            rollback_requests,
414            overhead_budget_us,
415            overhead_backoff_requests,
416        }
417    }
418}
419
420#[derive(Debug, Clone, Default)]
421struct DualExecOracleState {
422    sampled_total: u64,
423    matched_total: u64,
424    divergence_total: u64,
425    skipped_unsupported_total: u64,
426    skipped_overhead_total: u64,
427    divergence_window: VecDeque<bool>,
428    rollback_remaining: usize,
429    rollback_reason: Option<String>,
430    overhead_backoff_remaining: usize,
431}
432
433impl DualExecOracleState {
434    fn begin_request(&mut self) {
435        if self.rollback_remaining > 0 {
436            self.rollback_remaining = self.rollback_remaining.saturating_sub(1);
437            if self.rollback_remaining == 0 {
438                self.rollback_reason = None;
439            }
440        }
441        if self.overhead_backoff_remaining > 0 {
442            self.overhead_backoff_remaining = self.overhead_backoff_remaining.saturating_sub(1);
443        }
444    }
445
446    const fn rollback_active(&self) -> bool {
447        self.rollback_remaining > 0
448    }
449
450    const fn record_overhead_budget_exceeded(&mut self, config: DualExecOracleConfig) {
451        self.skipped_overhead_total = self.skipped_overhead_total.saturating_add(1);
452        self.overhead_backoff_remaining = config.overhead_backoff_requests;
453    }
454
455    fn record_sample(
456        &mut self,
457        divergent: bool,
458        config: DualExecOracleConfig,
459        extension_id: Option<&str>,
460    ) -> Option<String> {
461        self.sampled_total = self.sampled_total.saturating_add(1);
462        if divergent {
463            self.divergence_total = self.divergence_total.saturating_add(1);
464        } else {
465            self.matched_total = self.matched_total.saturating_add(1);
466        }
467        self.divergence_window.push_back(divergent);
468        while self.divergence_window.len() > config.divergence_window {
469            let _ = self.divergence_window.pop_front();
470        }
471        let divergence_count = self.divergence_window.iter().filter(|&&flag| flag).count();
472        if divergence_count >= config.divergence_budget {
473            self.rollback_remaining = config.rollback_requests;
474            let reason = format!(
475                "dual_exec_divergence_budget_exceeded:{divergence_count}/{window}:{scope}",
476                window = self.divergence_window.len(),
477                scope = extension_id.unwrap_or("global")
478            );
479            self.rollback_reason = Some(reason.clone());
480            return Some(reason);
481        }
482        None
483    }
484}
485
486#[derive(Debug, Clone, PartialEq, Eq)]
487struct DualExecOutcomeDiff {
488    reason: &'static str,
489    fast_fingerprint: String,
490    compat_fingerprint: String,
491}
492
493fn hostcall_value_fingerprint(value: &Value) -> String {
494    let mut hasher = sha2::Sha256::new();
495    hash_canonical_json(value, &mut hasher);
496    format!("{:x}", hasher.finalize())
497}
498
499fn hostcall_outcome_fingerprint(outcome: &HostcallOutcome) -> String {
500    match outcome {
501        HostcallOutcome::Success(output) => {
502            let hash = hostcall_value_fingerprint(output);
503            format!("success:{hash}")
504        }
505        HostcallOutcome::Error { code, message } => {
506            let hash = hostcall_value_fingerprint(&serde_json::json!({
507                "code": code,
508                "message": message,
509            }));
510            format!("error:{hash}")
511        }
512        HostcallOutcome::StreamChunk {
513            sequence,
514            chunk,
515            is_final,
516        } => {
517            let hash = hostcall_value_fingerprint(&serde_json::json!({
518                "sequence": sequence,
519                "chunk": chunk,
520                "isFinal": is_final,
521            }));
522            format!("stream:{hash}")
523        }
524    }
525}
526
527fn diff_hostcall_outcomes(
528    fast: &HostcallOutcome,
529    compat: &HostcallOutcome,
530) -> Option<DualExecOutcomeDiff> {
531    match (fast, compat) {
532        (HostcallOutcome::Success(a), HostcallOutcome::Success(b)) => {
533            let a_hash = hostcall_value_fingerprint(a);
534            let b_hash = hostcall_value_fingerprint(b);
535            if a_hash == b_hash {
536                None
537            } else {
538                Some(DualExecOutcomeDiff {
539                    reason: "success_output_mismatch",
540                    fast_fingerprint: format!("success:{a_hash}"),
541                    compat_fingerprint: format!("success:{b_hash}"),
542                })
543            }
544        }
545        (
546            HostcallOutcome::Error {
547                code: a_code,
548                message: a_message,
549            },
550            HostcallOutcome::Error {
551                code: b_code,
552                message: b_message,
553            },
554        ) => {
555            if a_code == b_code && a_message == b_message {
556                None
557            } else if a_code != b_code {
558                Some(DualExecOutcomeDiff {
559                    reason: "error_code_mismatch",
560                    fast_fingerprint: hostcall_outcome_fingerprint(fast),
561                    compat_fingerprint: hostcall_outcome_fingerprint(compat),
562                })
563            } else {
564                Some(DualExecOutcomeDiff {
565                    reason: "error_message_mismatch",
566                    fast_fingerprint: hostcall_outcome_fingerprint(fast),
567                    compat_fingerprint: hostcall_outcome_fingerprint(compat),
568                })
569            }
570        }
571        (
572            HostcallOutcome::StreamChunk {
573                sequence: a_seq,
574                chunk: a_chunk,
575                is_final: a_final,
576            },
577            HostcallOutcome::StreamChunk {
578                sequence: b_seq,
579                chunk: b_chunk,
580                is_final: b_final,
581            },
582        ) => {
583            if a_seq == b_seq && a_chunk == b_chunk && a_final == b_final {
584                None
585            } else if a_seq != b_seq {
586                Some(DualExecOutcomeDiff {
587                    reason: "stream_sequence_mismatch",
588                    fast_fingerprint: hostcall_outcome_fingerprint(fast),
589                    compat_fingerprint: hostcall_outcome_fingerprint(compat),
590                })
591            } else if a_final != b_final {
592                Some(DualExecOutcomeDiff {
593                    reason: "stream_finality_mismatch",
594                    fast_fingerprint: hostcall_outcome_fingerprint(fast),
595                    compat_fingerprint: hostcall_outcome_fingerprint(compat),
596                })
597            } else {
598                Some(DualExecOutcomeDiff {
599                    reason: "stream_chunk_mismatch",
600                    fast_fingerprint: hostcall_outcome_fingerprint(fast),
601                    compat_fingerprint: hostcall_outcome_fingerprint(compat),
602                })
603            }
604        }
605        _ => Some(DualExecOutcomeDiff {
606            reason: "outcome_variant_mismatch",
607            fast_fingerprint: hostcall_outcome_fingerprint(fast),
608            compat_fingerprint: hostcall_outcome_fingerprint(compat),
609        }),
610    }
611}
612
613fn should_sample_shadow_dual_exec(request: &HostcallRequest, sample_ppm: u32) -> bool {
614    if sample_ppm == 0 {
615        return false;
616    }
617    if sample_ppm >= DUAL_EXEC_SAMPLE_MODULUS_PPM {
618        return true;
619    }
620    let bucket = shadow_sampling_bucket(request) % DUAL_EXEC_SAMPLE_MODULUS_PPM;
621    bucket < sample_ppm
622}
623
624#[inline]
625fn fnv1a64_update(mut hash: u64, bytes: &[u8]) -> u64 {
626    const FNV1A_PRIME: u64 = 1_099_511_628_211;
627    for &byte in bytes {
628        hash ^= u64::from(byte);
629        hash = hash.wrapping_mul(FNV1A_PRIME);
630    }
631    hash
632}
633
634#[inline]
635fn shadow_sampling_bucket(request: &HostcallRequest) -> u32 {
636    // Deterministic, allocation-free mixing for high-frequency sampling checks.
637    const FNV1A_OFFSET_BASIS: u64 = 14_695_981_039_346_656_037;
638    let mut hash = FNV1A_OFFSET_BASIS;
639    hash = fnv1a64_update(hash, request.call_id.as_bytes());
640    hash = fnv1a64_update(hash, &[0xFF]);
641    hash = fnv1a64_update(hash, &request.trace_id.to_le_bytes());
642    if let Some(extension_id) = request.extension_id.as_deref() {
643        hash = fnv1a64_update(hash, &[0xFE]);
644        hash = fnv1a64_update(hash, extension_id.as_bytes());
645    }
646
647    // Final avalanche to improve low-bit dispersion before modulus.
648    hash ^= hash >> 33;
649    hash = hash.wrapping_mul(0xff51_afd7_ed55_8ccd);
650    hash ^= hash >> 33;
651    hash = hash.wrapping_mul(0xc4ce_b9fe_1a85_ec53);
652    hash ^= hash >> 33;
653
654    let bytes = hash.to_le_bytes();
655    let low = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
656    let high = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
657    low ^ high
658}
659
660fn normalized_shadow_op(op: &str) -> String {
661    let trimmed = op.trim();
662    let mut normalized = String::with_capacity(trimmed.len());
663    for ch in trimmed.chars() {
664        if ch != '_' {
665            normalized.push(ch.to_ascii_lowercase());
666        }
667    }
668    normalized
669}
670
671#[inline]
672fn with_folded_ascii_alnum_token<T>(token: &str, f: impl FnOnce(&[u8]) -> T) -> T {
673    const INLINE_CAP: usize = 64;
674    let mut inline = [0_u8; INLINE_CAP];
675    let mut inline_len = 0_usize;
676    let mut heap: Option<Vec<u8>> = None;
677
678    for byte in token.trim().bytes() {
679        if !byte.is_ascii_alphanumeric() {
680            continue;
681        }
682        let folded = byte.to_ascii_lowercase();
683        if let Some(buf) = heap.as_mut() {
684            buf.push(folded);
685            continue;
686        }
687        if inline_len < INLINE_CAP {
688            inline[inline_len] = folded;
689            inline_len += 1;
690        } else {
691            let mut buf = Vec::with_capacity(token.len());
692            buf.extend_from_slice(&inline[..inline_len]);
693            buf.push(folded);
694            heap = Some(buf);
695        }
696    }
697
698    if let Some(buf) = heap {
699        f(buf.as_slice())
700    } else {
701        f(&inline[..inline_len])
702    }
703}
704
705fn shadow_safe_session_op(op: &str) -> bool {
706    with_folded_ascii_alnum_token(op, |folded| {
707        matches!(
708            folded,
709            b"getstate"
710                | b"getmessages"
711                | b"getentries"
712                | b"getbranch"
713                | b"getfile"
714                | b"getname"
715                | b"getmodel"
716                | b"getthinkinglevel"
717                | b"getlabel"
718                | b"getlabels"
719                | b"getallsessions"
720        )
721    })
722}
723
724fn shadow_safe_events_op(op: &str) -> bool {
725    with_folded_ascii_alnum_token(op, |folded| {
726        matches!(
727            folded,
728            b"getactivetools"
729                | b"getalltools"
730                | b"getmodel"
731                | b"getthinkinglevel"
732                | b"getflag"
733                | b"listflags"
734        )
735    })
736}
737
738fn shadow_safe_tool(name: &str) -> bool {
739    let name = name.trim();
740    name.eq_ignore_ascii_case("read")
741        || name.eq_ignore_ascii_case("grep")
742        || name.eq_ignore_ascii_case("find")
743        || name.eq_ignore_ascii_case("ls")
744}
745
746fn is_shadow_safe_request(request: &HostcallRequest) -> bool {
747    match &request.kind {
748        HostcallKind::Session { op } => shadow_safe_session_op(op),
749        HostcallKind::Events { op } => shadow_safe_events_op(op),
750        HostcallKind::Tool { name } => shadow_safe_tool(name),
751        HostcallKind::Http
752        | HostcallKind::Exec { .. }
753        | HostcallKind::Ui { .. }
754        | HostcallKind::Log => false,
755    }
756}
757
758fn parse_env_bool(name: &str, default: bool) -> bool {
759    std::env::var(name).ok().map_or(default, |raw| {
760        match raw.trim().to_ascii_lowercase().as_str() {
761            "1" | "true" | "yes" | "on" | "enabled" => true,
762            "0" | "false" | "no" | "off" | "disabled" => false,
763            _ => default,
764        }
765    })
766}
767
768fn io_uring_lane_policy_from_env() -> IoUringLanePolicyConfig {
769    let default = IoUringLanePolicyConfig::conservative();
770    let max_queue_depth = std::env::var("PI_EXT_IO_URING_MAX_QUEUE_DEPTH")
771        .ok()
772        .and_then(|raw| raw.trim().parse::<usize>().ok())
773        .unwrap_or(default.max_queue_depth)
774        .max(1);
775
776    IoUringLanePolicyConfig {
777        enabled: parse_env_bool("PI_EXT_IO_URING_ENABLED", default.enabled),
778        ring_available: parse_env_bool("PI_EXT_IO_URING_RING_AVAILABLE", default.ring_available),
779        max_queue_depth,
780        allow_filesystem: parse_env_bool(
781            "PI_EXT_IO_URING_ALLOW_FILESYSTEM",
782            default.allow_filesystem,
783        ),
784        allow_network: parse_env_bool("PI_EXT_IO_URING_ALLOW_NETWORK", default.allow_network),
785    }
786}
787
788fn io_uring_force_compat_from_env() -> bool {
789    parse_env_bool("PI_EXT_IO_URING_FORCE_COMPAT", false)
790}
791
792fn hostcall_io_hint(kind: &HostcallKind) -> HostcallIoHint {
793    match kind {
794        HostcallKind::Http => HostcallIoHint::IoHeavy,
795        HostcallKind::Tool { name } => {
796            let name = name.trim();
797            if name.eq_ignore_ascii_case("read")
798                || name.eq_ignore_ascii_case("write")
799                || name.eq_ignore_ascii_case("edit")
800                || name.eq_ignore_ascii_case("grep")
801                || name.eq_ignore_ascii_case("find")
802                || name.eq_ignore_ascii_case("ls")
803            {
804                HostcallIoHint::IoHeavy
805            } else if name.eq_ignore_ascii_case("bash") {
806                HostcallIoHint::CpuBound
807            } else {
808                HostcallIoHint::Unknown
809            }
810        }
811        HostcallKind::Session { op } => {
812            let lower = op.trim().to_ascii_lowercase();
813            if lower.contains("save")
814                || lower.contains("append")
815                || lower.contains("write")
816                || lower.contains("export")
817                || lower.contains("import")
818            {
819                HostcallIoHint::IoHeavy
820            } else {
821                HostcallIoHint::Unknown
822            }
823        }
824        HostcallKind::Exec { .. }
825        | HostcallKind::Ui { .. }
826        | HostcallKind::Events { .. }
827        | HostcallKind::Log => HostcallIoHint::CpuBound,
828    }
829}
830
831const fn hostcall_io_hint_label(io_hint: HostcallIoHint) -> &'static str {
832    match io_hint {
833        HostcallIoHint::Unknown => "unknown",
834        HostcallIoHint::IoHeavy => "io_heavy",
835        HostcallIoHint::CpuBound => "cpu_bound",
836    }
837}
838
839const fn hostcall_capability_label(capability: HostcallCapabilityClass) -> &'static str {
840    match capability {
841        HostcallCapabilityClass::Filesystem => "filesystem",
842        HostcallCapabilityClass::Network => "network",
843        HostcallCapabilityClass::Execution => "execution",
844        HostcallCapabilityClass::Session => "session",
845        HostcallCapabilityClass::Events => "events",
846        HostcallCapabilityClass::Environment => "environment",
847        HostcallCapabilityClass::Tool => "tool",
848        HostcallCapabilityClass::Ui => "ui",
849        HostcallCapabilityClass::Telemetry => "telemetry",
850        HostcallCapabilityClass::Unknown => "unknown",
851    }
852}
853
854#[derive(Debug, Clone, Copy, PartialEq, Eq)]
855enum IoUringBridgeState {
856    DelegatedFastPath,
857    CancelledBeforeDispatch,
858    CancelledAfterDispatch,
859}
860
861impl IoUringBridgeState {
862    const fn as_str(self) -> &'static str {
863        match self {
864            Self::DelegatedFastPath => "delegated_fast_path",
865            Self::CancelledBeforeDispatch => "cancelled_before_dispatch",
866            Self::CancelledAfterDispatch => "cancelled_after_dispatch",
867        }
868    }
869}
870
871#[derive(Debug, Clone)]
872struct IoUringBridgeDispatch {
873    outcome: HostcallOutcome,
874    state: IoUringBridgeState,
875    fallback_reason: Option<&'static str>,
876}
877
878fn clone_payload_object_without_key(
879    map: &serde_json::Map<String, Value>,
880    reserved_key: &str,
881) -> serde_json::Map<String, Value> {
882    let mut out = serde_json::Map::with_capacity(map.len());
883    for (key, value) in map {
884        if key == reserved_key {
885            continue;
886        }
887        out.insert(key.clone(), value.clone());
888    }
889    out
890}
891
892fn clone_payload_object_without_two_keys(
893    map: &serde_json::Map<String, Value>,
894    reserved_a: &str,
895    reserved_b: &str,
896) -> serde_json::Map<String, Value> {
897    let mut out = serde_json::Map::with_capacity(map.len());
898    for (key, value) in map {
899        if key == reserved_a || key == reserved_b {
900            continue;
901        }
902        out.insert(key.clone(), value.clone());
903    }
904    out
905}
906
907fn protocol_params_from_request(request: &HostcallRequest) -> Value {
908    match &request.kind {
909        HostcallKind::Tool { name } => {
910            let mut object = serde_json::Map::with_capacity(2);
911            object.insert("name".to_string(), Value::String(name.clone()));
912            object.insert("input".to_string(), request.payload.clone());
913            Value::Object(object)
914        }
915        HostcallKind::Exec { cmd } => {
916            let mut object = match &request.payload {
917                Value::Object(map) => clone_payload_object_without_two_keys(map, "command", "cmd"),
918                Value::Null => serde_json::Map::new(),
919                other => {
920                    let mut out = serde_json::Map::new();
921                    out.insert("payload".to_string(), other.clone());
922                    out
923                }
924            };
925            object.insert("cmd".to_string(), Value::String(cmd.clone()));
926            Value::Object(object)
927        }
928        HostcallKind::Http | HostcallKind::Log => request.payload.clone(),
929        HostcallKind::Session { op } | HostcallKind::Ui { op } | HostcallKind::Events { op } => {
930            let mut object = match &request.payload {
931                Value::Object(map) => clone_payload_object_without_key(map, "op"),
932                Value::Null => serde_json::Map::new(),
933                other => {
934                    let mut out = serde_json::Map::new();
935                    out.insert("payload".to_string(), other.clone());
936                    out
937                }
938            };
939            object.insert("op".to_string(), Value::String(op.clone()));
940            Value::Object(object)
941        }
942    }
943}
944
945fn dual_exec_forensic_bundle(
946    request: &HostcallRequest,
947    diff: &DualExecOutcomeDiff,
948    rollback_reason: Option<&str>,
949    shadow_elapsed_us: f64,
950) -> Value {
951    serde_json::json!({
952        "call_trace": {
953            "call_id": request.call_id,
954            "trace_id": request.trace_id,
955            "extension_id": request.extension_id,
956            "method": request.method(),
957            "params_hash": request.params_hash(),
958            "capability": request.required_capability(),
959        },
960        "lane_decision": {
961            "fast_lane": "fast",
962            "compat_lane": "compat_shadow",
963        },
964        "diff": {
965            "reason": diff.reason,
966            "fast_fingerprint": diff.fast_fingerprint,
967            "compat_fingerprint": diff.compat_fingerprint,
968            "shadow_elapsed_us": shadow_elapsed_us,
969        },
970        "rollback": {
971            "triggered": rollback_reason.is_some(),
972            "reason": rollback_reason,
973        }
974    })
975}
976
977const REGIME_MIN_SAMPLES: usize = 24;
978const REGIME_CUSUM_DRIFT: f64 = 0.03;
979const REGIME_CUSUM_THRESHOLD: f64 = 1.6;
980const REGIME_BOCPD_HAZARD: f64 = 0.08;
981const REGIME_POSTERIOR_DECAY: f64 = 0.92;
982const REGIME_POSTERIOR_THRESHOLD: f64 = 0.45;
983const REGIME_COOLDOWN_OBSERVATIONS: usize = 32;
984const REGIME_CONFIRMATION_STREAK: usize = 2;
985const REGIME_FALLBACK_QUEUE_DEPTH: f64 = 1.0;
986const REGIME_FALLBACK_SERVICE_US: f64 = 1_200.0;
987const REGIME_VARIANCE_FLOOR: f64 = 1e-6;
988const ROLLOUT_ALPHA: f64 = 0.05;
989const ROLLOUT_HIGH_STRATUM_QUEUE_MIN: f64 = 8.0;
990const ROLLOUT_HIGH_STRATUM_SERVICE_US_MIN: f64 = 4_500.0;
991const ROLLOUT_LOW_STRATUM_QUEUE_MAX: f64 = 2.0;
992const ROLLOUT_LOW_STRATUM_SERVICE_US_MAX: f64 = 1_800.0;
993const ROLLOUT_PROMOTE_SCORE_THRESHOLD: f64 = 1.25;
994const ROLLOUT_ROLLBACK_SCORE_THRESHOLD: f64 = 0.70;
995const ROLLOUT_MIN_STRATUM_SAMPLES: usize = 10;
996const ROLLOUT_MIN_TOTAL_SAMPLES: usize = 30;
997const ROLLOUT_LOG_E_CLAMP: f64 = 120.0;
998const ROLLOUT_LR_NULL: f64 = 0.35;
999const ROLLOUT_LR_ALT: f64 = 0.65;
1000const ROLLOUT_FALSE_PROMOTE_LOSS: f64 = 28.0;
1001const ROLLOUT_FALSE_ROLLBACK_LOSS: f64 = 12.0;
1002const ROLLOUT_HOLD_OPPORTUNITY_LOSS: f64 = 10.0;
1003
1004#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1005enum RegimeAdaptationMode {
1006    SequentialFastPath,
1007    InterleavedBatching,
1008}
1009
1010impl RegimeAdaptationMode {
1011    const fn as_str(self) -> &'static str {
1012        match self {
1013            Self::SequentialFastPath => "sequential_fast_path",
1014            Self::InterleavedBatching => "interleaved_batching",
1015        }
1016    }
1017}
1018
1019#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1020enum RegimeTransition {
1021    EnterInterleavedBatching,
1022    ReturnToSequentialFastPath,
1023}
1024
1025impl RegimeTransition {
1026    const fn as_str(self) -> &'static str {
1027        match self {
1028            Self::EnterInterleavedBatching => "enter_interleaved_batching",
1029            Self::ReturnToSequentialFastPath => "return_to_sequential_fast_path",
1030        }
1031    }
1032}
1033
1034#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1035enum RolloutGateAction {
1036    Hold,
1037    PromoteInterleaved,
1038    RollbackSequential,
1039}
1040
1041impl RolloutGateAction {
1042    const fn as_str(self) -> &'static str {
1043        match self {
1044            Self::Hold => "hold",
1045            Self::PromoteInterleaved => "promote_interleaved",
1046            Self::RollbackSequential => "rollback_sequential",
1047        }
1048    }
1049}
1050
1051#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1052enum RolloutEvidenceStratum {
1053    HighContention,
1054    LowContention,
1055    Mixed,
1056}
1057
1058impl RolloutEvidenceStratum {
1059    const fn as_str(self) -> &'static str {
1060        match self {
1061            Self::HighContention => "high_contention",
1062            Self::LowContention => "low_contention",
1063            Self::Mixed => "mixed",
1064        }
1065    }
1066}
1067
1068#[derive(Debug, Clone, Copy)]
1069struct RolloutExpectedLoss {
1070    hold: f64,
1071    promote: f64,
1072    rollback: f64,
1073}
1074
1075#[derive(Debug, Clone, Copy)]
1076struct RolloutGateDecision {
1077    action: RolloutGateAction,
1078    expected_loss: RolloutExpectedLoss,
1079    promote_posterior: f64,
1080    rollback_posterior: f64,
1081    promote_e_process: f64,
1082    rollback_e_process: f64,
1083    evidence_threshold: f64,
1084    total_samples: usize,
1085    high_samples: usize,
1086    low_samples: usize,
1087    coverage_ready: bool,
1088    blocked_underpowered: bool,
1089    blocked_cherry_picked: bool,
1090}
1091
1092#[derive(Debug, Clone)]
1093struct RolloutGateState {
1094    total_samples: usize,
1095    high_samples: usize,
1096    low_samples: usize,
1097    promote_alpha: f64,
1098    promote_beta: f64,
1099    rollback_alpha: f64,
1100    rollback_beta: f64,
1101    promote_log_e: f64,
1102    rollback_log_e: f64,
1103}
1104
1105impl Default for RolloutGateState {
1106    fn default() -> Self {
1107        Self {
1108            total_samples: 0,
1109            high_samples: 0,
1110            low_samples: 0,
1111            promote_alpha: 1.0,
1112            promote_beta: 1.0,
1113            rollback_alpha: 1.0,
1114            rollback_beta: 1.0,
1115            promote_log_e: 0.0,
1116            rollback_log_e: 0.0,
1117        }
1118    }
1119}
1120
1121#[derive(Debug, Clone, Copy)]
1122struct RegimeSignal {
1123    queue_depth: f64,
1124    service_time_us: f64,
1125    opcode_entropy: f64,
1126    llc_miss_rate: f64,
1127}
1128
1129impl RegimeSignal {
1130    fn composite_score(self) -> f64 {
1131        let queue_component = (self.queue_depth / 32.0).min(4.0);
1132        let service_component = (self.service_time_us / 5_000.0).min(4.0);
1133        let entropy_component = (self.opcode_entropy / 4.0).min(2.0);
1134        let llc_component = self.llc_miss_rate.clamp(0.0, 1.0) * 2.0;
1135        0.15f64.mul_add(
1136            llc_component,
1137            0.15f64.mul_add(
1138                entropy_component,
1139                0.35f64.mul_add(queue_component, 0.35 * service_component),
1140            ),
1141        )
1142    }
1143}
1144
1145#[derive(Debug, Clone, Copy)]
1146#[allow(clippy::struct_excessive_bools)]
1147struct RegimeObservation {
1148    score: f64,
1149    mean: f64,
1150    stddev: f64,
1151    upper_cusum: f64,
1152    lower_cusum: f64,
1153    change_posterior: f64,
1154    transition: Option<RegimeTransition>,
1155    mode: RegimeAdaptationMode,
1156    fallback_triggered: bool,
1157    rollout_action: RolloutGateAction,
1158    rollout_stratum: RolloutEvidenceStratum,
1159    rollout_expected_loss: RolloutExpectedLoss,
1160    rollout_promote_posterior: f64,
1161    rollout_rollback_posterior: f64,
1162    rollout_promote_e_process: f64,
1163    rollout_rollback_e_process: f64,
1164    rollout_evidence_threshold: f64,
1165    rollout_total_samples: usize,
1166    rollout_high_samples: usize,
1167    rollout_low_samples: usize,
1168    rollout_coverage_ready: bool,
1169    rollout_blocked_underpowered: bool,
1170    rollout_blocked_cherry_picked: bool,
1171}
1172
1173#[derive(Debug, Clone)]
1174struct RegimeShiftDetector {
1175    sample_count: usize,
1176    mean: f64,
1177    m2: f64,
1178    upper_cusum: f64,
1179    lower_cusum: f64,
1180    change_posterior: f64,
1181    cooldown_remaining: usize,
1182    confirmation_streak: usize,
1183    mode: RegimeAdaptationMode,
1184    rollout_gate: RolloutGateState,
1185}
1186
1187impl Default for RegimeShiftDetector {
1188    fn default() -> Self {
1189        Self {
1190            sample_count: 0,
1191            mean: 0.0,
1192            m2: 0.0,
1193            upper_cusum: 0.0,
1194            lower_cusum: 0.0,
1195            change_posterior: 0.0,
1196            cooldown_remaining: 0,
1197            confirmation_streak: 0,
1198            mode: RegimeAdaptationMode::SequentialFastPath,
1199            rollout_gate: RolloutGateState::default(),
1200        }
1201    }
1202}
1203
1204impl RegimeShiftDetector {
1205    const fn current_mode(&self) -> RegimeAdaptationMode {
1206        self.mode
1207    }
1208
1209    #[allow(clippy::too_many_lines)]
1210    fn observe(&mut self, signal: RegimeSignal) -> RegimeObservation {
1211        let score = signal.composite_score();
1212        let baseline_mean = self.mean;
1213        let baseline_stddev = self.variance().sqrt().max(REGIME_VARIANCE_FLOOR);
1214        let deviation = if self.sample_count > 1 {
1215            score - baseline_mean
1216        } else {
1217            0.0
1218        };
1219
1220        self.upper_cusum = (self.upper_cusum + deviation - REGIME_CUSUM_DRIFT).max(0.0);
1221        self.lower_cusum = (self.lower_cusum + deviation + REGIME_CUSUM_DRIFT).min(0.0);
1222
1223        let z_score = if baseline_stddev > REGIME_VARIANCE_FLOOR {
1224            deviation / baseline_stddev
1225        } else {
1226            0.0
1227        };
1228        let evidence = (z_score.abs() - 0.8).max(0.0);
1229        let change_likelihood = 1.0 - (-evidence).exp();
1230        self.change_posterior = self
1231            .change_posterior
1232            .mul_add(
1233                REGIME_POSTERIOR_DECAY,
1234                REGIME_BOCPD_HAZARD * change_likelihood,
1235            )
1236            .clamp(0.0, 1.0);
1237
1238        let cusum_triggered = self.upper_cusum >= REGIME_CUSUM_THRESHOLD
1239            || self.lower_cusum <= -REGIME_CUSUM_THRESHOLD;
1240        let posterior_triggered = self.change_posterior >= REGIME_POSTERIOR_THRESHOLD;
1241        let candidate_shift =
1242            self.sample_count >= REGIME_MIN_SAMPLES && cusum_triggered && posterior_triggered;
1243        let direction_is_up = self.upper_cusum >= -self.lower_cusum;
1244        let rollout_stratum = rollout_evidence_stratum(signal);
1245        let rollout_decision = self.rollout_gate.observe(
1246            score,
1247            rollout_stratum,
1248            self.mode,
1249            candidate_shift,
1250            direction_is_up,
1251        );
1252
1253        let mut transition = None;
1254        let mut fallback_triggered = false;
1255
1256        if self.cooldown_remaining > 0 {
1257            self.cooldown_remaining = self.cooldown_remaining.saturating_sub(1);
1258            self.confirmation_streak = 0;
1259        } else {
1260            let desired_mode = match rollout_decision.action {
1261                RolloutGateAction::PromoteInterleaved => {
1262                    Some(RegimeAdaptationMode::InterleavedBatching)
1263                }
1264                RolloutGateAction::RollbackSequential => {
1265                    Some(RegimeAdaptationMode::SequentialFastPath)
1266                }
1267                RolloutGateAction::Hold => None,
1268            };
1269            if let Some(desired_mode) = desired_mode {
1270                if desired_mode == self.mode {
1271                    self.confirmation_streak = 0;
1272                } else {
1273                    self.confirmation_streak = self.confirmation_streak.saturating_add(1);
1274                    if self.confirmation_streak >= REGIME_CONFIRMATION_STREAK {
1275                        self.mode = desired_mode;
1276                        transition = Some(match desired_mode {
1277                            RegimeAdaptationMode::InterleavedBatching => {
1278                                RegimeTransition::EnterInterleavedBatching
1279                            }
1280                            RegimeAdaptationMode::SequentialFastPath => {
1281                                RegimeTransition::ReturnToSequentialFastPath
1282                            }
1283                        });
1284                        self.cooldown_remaining = REGIME_COOLDOWN_OBSERVATIONS;
1285                        self.upper_cusum = 0.0;
1286                        self.lower_cusum = 0.0;
1287                        self.change_posterior = self.change_posterior.min(0.5);
1288                        self.confirmation_streak = 0;
1289                    }
1290                }
1291            } else {
1292                self.confirmation_streak = 0;
1293            }
1294        }
1295
1296        if self.mode == RegimeAdaptationMode::InterleavedBatching
1297            && signal.queue_depth <= REGIME_FALLBACK_QUEUE_DEPTH
1298            && signal.service_time_us <= REGIME_FALLBACK_SERVICE_US
1299        {
1300            self.mode = RegimeAdaptationMode::SequentialFastPath;
1301            transition = Some(RegimeTransition::ReturnToSequentialFastPath);
1302            fallback_triggered = true;
1303            self.cooldown_remaining = REGIME_COOLDOWN_OBSERVATIONS / 2;
1304            self.upper_cusum = 0.0;
1305            self.lower_cusum = 0.0;
1306            self.change_posterior = self.change_posterior.min(0.25);
1307            self.confirmation_streak = 0;
1308        }
1309
1310        self.sample_count = self.sample_count.saturating_add(1);
1311        if self.sample_count == 1 {
1312            self.mean = score;
1313            self.m2 = 0.0;
1314        } else {
1315            let count_f64 = f64::from(u32::try_from(self.sample_count).unwrap_or(u32::MAX));
1316            let delta = score - self.mean;
1317            self.mean += delta / count_f64;
1318            let delta2 = score - self.mean;
1319            self.m2 = delta.mul_add(delta2, self.m2);
1320        }
1321
1322        RegimeObservation {
1323            score,
1324            mean: self.mean,
1325            stddev: self.variance().sqrt().max(REGIME_VARIANCE_FLOOR),
1326            upper_cusum: self.upper_cusum,
1327            lower_cusum: self.lower_cusum,
1328            change_posterior: self.change_posterior,
1329            transition,
1330            mode: self.mode,
1331            fallback_triggered,
1332            rollout_action: rollout_decision.action,
1333            rollout_stratum,
1334            rollout_expected_loss: rollout_decision.expected_loss,
1335            rollout_promote_posterior: rollout_decision.promote_posterior,
1336            rollout_rollback_posterior: rollout_decision.rollback_posterior,
1337            rollout_promote_e_process: rollout_decision.promote_e_process,
1338            rollout_rollback_e_process: rollout_decision.rollback_e_process,
1339            rollout_evidence_threshold: rollout_decision.evidence_threshold,
1340            rollout_total_samples: rollout_decision.total_samples,
1341            rollout_high_samples: rollout_decision.high_samples,
1342            rollout_low_samples: rollout_decision.low_samples,
1343            rollout_coverage_ready: rollout_decision.coverage_ready,
1344            rollout_blocked_underpowered: rollout_decision.blocked_underpowered,
1345            rollout_blocked_cherry_picked: rollout_decision.blocked_cherry_picked,
1346        }
1347    }
1348
1349    fn variance(&self) -> f64 {
1350        if self.sample_count < 2 {
1351            REGIME_VARIANCE_FLOOR
1352        } else {
1353            let denom =
1354                f64::from(u32::try_from(self.sample_count.saturating_sub(1)).unwrap_or(u32::MAX));
1355            (self.m2 / denom).max(REGIME_VARIANCE_FLOOR)
1356        }
1357    }
1358}
1359
1360impl RolloutGateState {
1361    fn observe(
1362        &mut self,
1363        score: f64,
1364        stratum: RolloutEvidenceStratum,
1365        mode: RegimeAdaptationMode,
1366        _candidate_shift: bool,
1367        _direction_is_up: bool,
1368    ) -> RolloutGateDecision {
1369        self.total_samples = self.total_samples.saturating_add(1);
1370        match stratum {
1371            RolloutEvidenceStratum::HighContention => {
1372                self.high_samples = self.high_samples.saturating_add(1);
1373            }
1374            RolloutEvidenceStratum::LowContention => {
1375                self.low_samples = self.low_samples.saturating_add(1);
1376            }
1377            RolloutEvidenceStratum::Mixed => {}
1378        }
1379
1380        match stratum {
1381            RolloutEvidenceStratum::HighContention => {
1382                let promote_signal = score >= ROLLOUT_PROMOTE_SCORE_THRESHOLD;
1383                if promote_signal {
1384                    self.promote_alpha += 1.0;
1385                } else {
1386                    self.promote_beta += 1.0;
1387                }
1388                self.promote_log_e = (self.promote_log_e
1389                    + bernoulli_log_likelihood_ratio(
1390                        promote_signal,
1391                        ROLLOUT_LR_NULL,
1392                        ROLLOUT_LR_ALT,
1393                    ))
1394                .clamp(-ROLLOUT_LOG_E_CLAMP, ROLLOUT_LOG_E_CLAMP);
1395            }
1396            RolloutEvidenceStratum::LowContention => {
1397                let rollback_signal = score <= ROLLOUT_ROLLBACK_SCORE_THRESHOLD;
1398                if rollback_signal {
1399                    self.rollback_alpha += 1.0;
1400                } else {
1401                    self.rollback_beta += 1.0;
1402                }
1403                self.rollback_log_e = (self.rollback_log_e
1404                    + bernoulli_log_likelihood_ratio(
1405                        rollback_signal,
1406                        ROLLOUT_LR_NULL,
1407                        ROLLOUT_LR_ALT,
1408                    ))
1409                .clamp(-ROLLOUT_LOG_E_CLAMP, ROLLOUT_LOG_E_CLAMP);
1410            }
1411            RolloutEvidenceStratum::Mixed => {}
1412        }
1413
1414        let promote_posterior = self.promote_alpha / (self.promote_alpha + self.promote_beta);
1415        let rollback_posterior = self.rollback_alpha / (self.rollback_alpha + self.rollback_beta);
1416        let promote_e_process = self.promote_log_e.exp();
1417        let rollback_e_process = self.rollback_log_e.exp();
1418        let evidence_threshold = 1.0 / ROLLOUT_ALPHA;
1419        let expected_loss = rollout_expected_loss(mode, promote_posterior, rollback_posterior);
1420
1421        let blocked_underpowered = self.total_samples < ROLLOUT_MIN_TOTAL_SAMPLES;
1422        let blocked_cherry_picked = self.high_samples < ROLLOUT_MIN_STRATUM_SAMPLES
1423            || self.low_samples < ROLLOUT_MIN_STRATUM_SAMPLES;
1424        let coverage_ready = !blocked_underpowered && !blocked_cherry_picked;
1425
1426        let promote_ready = coverage_ready
1427            && mode == RegimeAdaptationMode::SequentialFastPath
1428            && promote_e_process >= evidence_threshold
1429            && expected_loss.promote < expected_loss.hold;
1430
1431        let rollback_ready = coverage_ready
1432            && mode == RegimeAdaptationMode::InterleavedBatching
1433            && rollback_e_process >= evidence_threshold
1434            && expected_loss.rollback < expected_loss.hold;
1435
1436        let action = if promote_ready {
1437            RolloutGateAction::PromoteInterleaved
1438        } else if rollback_ready {
1439            RolloutGateAction::RollbackSequential
1440        } else {
1441            RolloutGateAction::Hold
1442        };
1443
1444        RolloutGateDecision {
1445            action,
1446            expected_loss,
1447            promote_posterior,
1448            rollback_posterior,
1449            promote_e_process,
1450            rollback_e_process,
1451            evidence_threshold,
1452            total_samples: self.total_samples,
1453            high_samples: self.high_samples,
1454            low_samples: self.low_samples,
1455            coverage_ready,
1456            blocked_underpowered,
1457            blocked_cherry_picked,
1458        }
1459    }
1460}
1461
1462fn rollout_evidence_stratum(signal: RegimeSignal) -> RolloutEvidenceStratum {
1463    if signal.queue_depth >= ROLLOUT_HIGH_STRATUM_QUEUE_MIN
1464        || signal.service_time_us >= ROLLOUT_HIGH_STRATUM_SERVICE_US_MIN
1465    {
1466        RolloutEvidenceStratum::HighContention
1467    } else if signal.queue_depth <= ROLLOUT_LOW_STRATUM_QUEUE_MAX
1468        && signal.service_time_us <= ROLLOUT_LOW_STRATUM_SERVICE_US_MAX
1469    {
1470        RolloutEvidenceStratum::LowContention
1471    } else {
1472        RolloutEvidenceStratum::Mixed
1473    }
1474}
1475
1476fn bernoulli_log_likelihood_ratio(observed_true: bool, p0: f64, p1: f64) -> f64 {
1477    let p0 = p0.clamp(1e-6, 1.0 - 1e-6);
1478    let p1 = p1.clamp(1e-6, 1.0 - 1e-6);
1479    if observed_true {
1480        f64::ln(p1 / p0)
1481    } else {
1482        f64::ln((1.0 - p1) / (1.0 - p0))
1483    }
1484}
1485
1486fn rollout_expected_loss(
1487    mode: RegimeAdaptationMode,
1488    promote_posterior: f64,
1489    rollback_posterior: f64,
1490) -> RolloutExpectedLoss {
1491    let hold = ROLLOUT_HOLD_OPPORTUNITY_LOSS
1492        .mul_add(promote_posterior, 3.0f64.mul_add(rollback_posterior, 1.0));
1493    let promote = match mode {
1494        RegimeAdaptationMode::SequentialFastPath => {
1495            ROLLOUT_FALSE_PROMOTE_LOSS.mul_add(1.0 - promote_posterior, 2.0 * rollback_posterior)
1496        }
1497        RegimeAdaptationMode::InterleavedBatching => ROLLOUT_FALSE_PROMOTE_LOSS
1498            .mul_add(1.0 - promote_posterior, ROLLOUT_HOLD_OPPORTUNITY_LOSS),
1499    };
1500    let rollback = match mode {
1501        RegimeAdaptationMode::SequentialFastPath => ROLLOUT_FALSE_ROLLBACK_LOSS
1502            .mul_add(1.0 - rollback_posterior, ROLLOUT_HOLD_OPPORTUNITY_LOSS),
1503        RegimeAdaptationMode::InterleavedBatching => {
1504            ROLLOUT_FALSE_ROLLBACK_LOSS.mul_add(1.0 - rollback_posterior, 2.0 * promote_posterior)
1505        }
1506    };
1507
1508    RolloutExpectedLoss {
1509        hold,
1510        promote,
1511        rollback,
1512    }
1513}
1514
1515fn usize_to_f64(value: usize) -> f64 {
1516    f64::from(u32::try_from(value).unwrap_or(u32::MAX))
1517}
1518
1519fn llc_miss_proxy(total_depth: usize, overflow_depth: usize, overflow_rejected_total: u64) -> f64 {
1520    if total_depth == 0 && overflow_rejected_total == 0 {
1521        return 0.0;
1522    }
1523    let depth_denominator = usize_to_f64(total_depth.max(1));
1524    let overflow_ratio = usize_to_f64(overflow_depth) / depth_denominator;
1525    let rejected_ratio = if overflow_rejected_total == 0 {
1526        0.0
1527    } else {
1528        let rejected = overflow_rejected_total.min(u64::from(u32::MAX));
1529        f64::from(u32::try_from(rejected).unwrap_or(u32::MAX)) / 1_000.0
1530    };
1531    (overflow_ratio + rejected_ratio).clamp(0.0, 1.0)
1532}
1533
1534const fn hostcall_kind_label(kind: &HostcallKind) -> &'static str {
1535    match kind {
1536        HostcallKind::Tool { .. } => "tool",
1537        HostcallKind::Exec { .. } => "exec",
1538        HostcallKind::Http => "http",
1539        HostcallKind::Session { .. } => "session",
1540        HostcallKind::Ui { .. } => "ui",
1541        HostcallKind::Events { .. } => "events",
1542        HostcallKind::Log => "log",
1543    }
1544}
1545
1546fn shannon_entropy_bytes(bytes: &[u8]) -> f64 {
1547    if bytes.is_empty() {
1548        return 0.0;
1549    }
1550    let mut counts = [0_u32; 256];
1551    for &byte in bytes {
1552        counts[usize::from(byte)] = counts[usize::from(byte)].saturating_add(1);
1553    }
1554    let total = f64::from(u32::try_from(bytes.len()).unwrap_or(u32::MAX));
1555    counts
1556        .iter()
1557        .filter(|&&count| count > 0)
1558        .map(|&count| {
1559            let probability = f64::from(count) / total;
1560            -(probability * (probability.ln() / std::f64::consts::LN_2))
1561        })
1562        .sum()
1563}
1564
1565fn hostcall_opcode_entropy(kind: &HostcallKind, payload: &Value) -> f64 {
1566    let kind_label = hostcall_kind_label(kind);
1567    let op = payload
1568        .get("op")
1569        .or_else(|| payload.get("method"))
1570        .or_else(|| payload.get("name"))
1571        .and_then(Value::as_str)
1572        .map(str::trim)
1573        .filter(|value| !value.is_empty());
1574    let capability = payload
1575        .get("capability")
1576        .and_then(Value::as_str)
1577        .map(str::trim)
1578        .filter(|value| !value.is_empty());
1579
1580    // Build the byte histogram directly from segments to avoid per-call
1581    // temporary Vec allocation on the hostcall fast path.
1582    let mut counts = [0_u32; 256];
1583    let mut total = 0_u32;
1584
1585    for &byte in kind_label.as_bytes() {
1586        counts[usize::from(byte)] = counts[usize::from(byte)].saturating_add(1);
1587        total = total.saturating_add(1);
1588    }
1589
1590    if let Some(op) = op {
1591        counts[usize::from(b':')] = counts[usize::from(b':')].saturating_add(1);
1592        total = total.saturating_add(1);
1593        for &byte in op.as_bytes() {
1594            counts[usize::from(byte)] = counts[usize::from(byte)].saturating_add(1);
1595            total = total.saturating_add(1);
1596        }
1597    }
1598
1599    if let Some(capability) = capability {
1600        counts[usize::from(b':')] = counts[usize::from(b':')].saturating_add(1);
1601        total = total.saturating_add(1);
1602        for &byte in capability.as_bytes() {
1603            counts[usize::from(byte)] = counts[usize::from(byte)].saturating_add(1);
1604            total = total.saturating_add(1);
1605        }
1606    }
1607
1608    if total == 0 {
1609        return 0.0;
1610    }
1611
1612    let total_f = f64::from(total);
1613    counts
1614        .iter()
1615        .filter(|&&count| count > 0)
1616        .map(|&count| {
1617            let probability = f64::from(count) / total_f;
1618            -(probability * (probability.ln() / std::f64::consts::LN_2))
1619        })
1620        .sum()
1621}
1622
1623impl<C: SchedulerClock + 'static> ExtensionDispatcher<C> {
1624    fn js_runtime(&self) -> &PiJsRuntime<C> {
1625        self.runtime.as_js_runtime()
1626    }
1627
1628    #[allow(clippy::too_many_arguments)]
1629    pub fn new<R>(
1630        runtime: Rc<R>,
1631        tool_registry: Arc<ToolRegistry>,
1632        http_connector: Arc<HttpConnector>,
1633        session: Arc<dyn ExtensionSession + Send + Sync>,
1634        ui_handler: Arc<dyn ExtensionUiHandler + Send + Sync>,
1635        cwd: PathBuf,
1636    ) -> Self
1637    where
1638        R: ExtensionDispatcherRuntime<C>,
1639    {
1640        Self::new_with_policy(
1641            runtime,
1642            tool_registry,
1643            http_connector,
1644            session,
1645            ui_handler,
1646            cwd,
1647            ExtensionPolicy::from_profile(PolicyProfile::Permissive),
1648        )
1649    }
1650
1651    #[allow(clippy::too_many_arguments)]
1652    pub fn new_with_policy<R>(
1653        runtime: Rc<R>,
1654        tool_registry: Arc<ToolRegistry>,
1655        http_connector: Arc<HttpConnector>,
1656        session: Arc<dyn ExtensionSession + Send + Sync>,
1657        ui_handler: Arc<dyn ExtensionUiHandler + Send + Sync>,
1658        cwd: PathBuf,
1659        policy: ExtensionPolicy,
1660    ) -> Self
1661    where
1662        R: ExtensionDispatcherRuntime<C>,
1663    {
1664        Self::new_with_policy_and_oracle_config(
1665            runtime,
1666            tool_registry,
1667            http_connector,
1668            session,
1669            ui_handler,
1670            cwd,
1671            policy,
1672            DualExecOracleConfig::from_env(),
1673        )
1674    }
1675
1676    #[allow(clippy::too_many_arguments)]
1677    fn new_with_policy_and_oracle_config<R>(
1678        runtime: Rc<R>,
1679        tool_registry: Arc<ToolRegistry>,
1680        http_connector: Arc<HttpConnector>,
1681        session: Arc<dyn ExtensionSession + Send + Sync>,
1682        ui_handler: Arc<dyn ExtensionUiHandler + Send + Sync>,
1683        cwd: PathBuf,
1684        policy: ExtensionPolicy,
1685        dual_exec_config: DualExecOracleConfig,
1686    ) -> Self
1687    where
1688        R: ExtensionDispatcherRuntime<C>,
1689    {
1690        let runtime: Rc<dyn ExtensionDispatcherRuntime<C>> = runtime;
1691        let snapshot_version = policy_snapshot_version(&policy);
1692        let snapshot = PolicySnapshot::compile(&policy);
1693        let io_uring_lane_config = io_uring_lane_policy_from_env();
1694        let io_uring_force_compat = io_uring_force_compat_from_env();
1695        Self {
1696            runtime,
1697            tool_registry,
1698            http_connector,
1699            session,
1700            ui_handler,
1701            cwd,
1702            policy,
1703            snapshot,
1704            snapshot_version,
1705            dual_exec_config,
1706            dual_exec_state: RefCell::new(DualExecOracleState::default()),
1707            io_uring_lane_config,
1708            io_uring_force_compat,
1709            regime_detector: RefCell::new(RegimeShiftDetector::default()),
1710            amac_executor: RefCell::new(
1711                AmacBatchExecutor::new(AmacBatchExecutorConfig::from_env()),
1712            ),
1713        }
1714    }
1715
1716    fn policy_lookup(
1717        &self,
1718        capability: &str,
1719        extension_id: Option<&str>,
1720    ) -> (PolicyCheck, &'static str) {
1721        (
1722            self.snapshot.lookup(capability, extension_id),
1723            policy_lookup_path(capability),
1724        )
1725    }
1726
1727    fn emit_policy_decision_telemetry(
1728        &self,
1729        capability: &str,
1730        extension_id: Option<&str>,
1731        lookup_path: &str,
1732        check: &PolicyCheck,
1733    ) {
1734        tracing::debug!(
1735            target: "pi.extensions.policy_snapshot",
1736            snapshot_version = %self.snapshot_version,
1737            lookup_path,
1738            capability = %capability,
1739            extension_id = %extension_id.unwrap_or("<none>"),
1740            decision = ?check.decision,
1741            decision_provenance = %check.reason,
1742            "Extension policy decision evaluated"
1743        );
1744    }
1745
1746    fn emit_regime_observation_telemetry(
1747        call_id: &str,
1748        observation: RegimeObservation,
1749        queue_depth: usize,
1750        overflow_depth: usize,
1751        overflow_rejected_total: u64,
1752        service_time_us: f64,
1753    ) {
1754        tracing::debug!(
1755            target: "pi.extensions.regime_shift",
1756            call_id,
1757            adaptation_mode = observation.mode.as_str(),
1758            composite_score = observation.score,
1759            baseline_mean = observation.mean,
1760            baseline_stddev = observation.stddev,
1761            upper_cusum = observation.upper_cusum,
1762            lower_cusum = observation.lower_cusum,
1763            change_posterior = observation.change_posterior,
1764            queue_depth,
1765            overflow_depth,
1766            overflow_rejected_total,
1767            service_time_us,
1768            fallback_triggered = observation.fallback_triggered,
1769            rollout_action = observation.rollout_action.as_str(),
1770            rollout_stratum = observation.rollout_stratum.as_str(),
1771            rollout_promote_posterior = observation.rollout_promote_posterior,
1772            rollout_rollback_posterior = observation.rollout_rollback_posterior,
1773            rollout_promote_e_process = observation.rollout_promote_e_process,
1774            rollout_rollback_e_process = observation.rollout_rollback_e_process,
1775            rollout_evidence_threshold = observation.rollout_evidence_threshold,
1776            rollout_expected_loss_hold = observation.rollout_expected_loss.hold,
1777            rollout_expected_loss_promote = observation.rollout_expected_loss.promote,
1778            rollout_expected_loss_rollback = observation.rollout_expected_loss.rollback,
1779            rollout_samples_total = observation.rollout_total_samples,
1780            rollout_samples_high = observation.rollout_high_samples,
1781            rollout_samples_low = observation.rollout_low_samples,
1782            rollout_coverage_ready = observation.rollout_coverage_ready,
1783            rollout_blocked_underpowered = observation.rollout_blocked_underpowered,
1784            rollout_blocked_cherry_picked = observation.rollout_blocked_cherry_picked,
1785            "Hostcall regime observation recorded"
1786        );
1787        if let Some(transition) = observation.transition {
1788            tracing::info!(
1789                target: "pi.extensions.regime_shift",
1790                call_id,
1791                transition = transition.as_str(),
1792                adaptation_mode = observation.mode.as_str(),
1793                score = observation.score,
1794                change_posterior = observation.change_posterior,
1795                queue_depth,
1796                service_time_us,
1797                fallback_triggered = observation.fallback_triggered,
1798                rollout_action = observation.rollout_action.as_str(),
1799                rollout_promote_posterior = observation.rollout_promote_posterior,
1800                rollout_rollback_posterior = observation.rollout_rollback_posterior,
1801                rollout_promote_e_process = observation.rollout_promote_e_process,
1802                rollout_rollback_e_process = observation.rollout_rollback_e_process,
1803                rollout_expected_loss_hold = observation.rollout_expected_loss.hold,
1804                rollout_expected_loss_promote = observation.rollout_expected_loss.promote,
1805                rollout_expected_loss_rollback = observation.rollout_expected_loss.rollback,
1806                rollout_samples_total = observation.rollout_total_samples,
1807                rollout_samples_high = observation.rollout_high_samples,
1808                rollout_samples_low = observation.rollout_low_samples,
1809                rollout_coverage_ready = observation.rollout_coverage_ready,
1810                rollout_blocked_underpowered = observation.rollout_blocked_underpowered,
1811                rollout_blocked_cherry_picked = observation.rollout_blocked_cherry_picked,
1812                "Hostcall regime transition accepted"
1813            );
1814        }
1815    }
1816
1817    #[allow(clippy::too_many_arguments)]
1818    fn emit_io_uring_lane_telemetry(
1819        &self,
1820        request: &HostcallRequest,
1821        capability: &str,
1822        capability_class: HostcallCapabilityClass,
1823        io_hint: HostcallIoHint,
1824        queue_depth: usize,
1825        selected_lane: HostcallDispatchLane,
1826        fallback_reason: Option<&'static str>,
1827    ) {
1828        let queue_budget = self.io_uring_lane_config.max_queue_depth.max(1);
1829        let depth_u64 = u64::try_from(queue_depth).unwrap_or(u64::MAX);
1830        let budget_u64 = u64::try_from(queue_budget).unwrap_or(u64::MAX).max(1);
1831        let occupancy_permille = depth_u64.saturating_mul(1_000).saturating_div(budget_u64);
1832        tracing::debug!(
1833            target: "pi.extensions.io_uring_lane",
1834            call_id = request.call_id,
1835            extension_id = %request.extension_id.as_deref().unwrap_or("<none>"),
1836            method = request.method(),
1837            capability = %capability,
1838            capability_class = hostcall_capability_label(capability_class),
1839            io_hint = hostcall_io_hint_label(io_hint),
1840            selected_lane = selected_lane.as_str(),
1841            fallback_reason = %fallback_reason.unwrap_or("none"),
1842            queue_depth,
1843            queue_budget,
1844            queue_occupancy_permille = occupancy_permille,
1845            io_uring_enabled = self.io_uring_lane_config.enabled,
1846            io_uring_ring_available = self.io_uring_lane_config.ring_available,
1847            io_uring_force_compat = self.io_uring_force_compat,
1848            "Hostcall io_uring lane decision evaluated"
1849        );
1850    }
1851
1852    fn emit_io_uring_bridge_telemetry(
1853        &self,
1854        request: &HostcallRequest,
1855        state: IoUringBridgeState,
1856        fallback_reason: Option<&'static str>,
1857    ) {
1858        tracing::debug!(
1859            target: "pi.extensions.io_uring_bridge",
1860            call_id = request.call_id,
1861            extension_id = %request.extension_id.as_deref().unwrap_or("<none>"),
1862            method = request.method(),
1863            state = state.as_str(),
1864            fallback_reason = %fallback_reason.unwrap_or("none"),
1865            io_uring_enabled = self.io_uring_lane_config.enabled,
1866            io_uring_ring_available = self.io_uring_lane_config.ring_available,
1867            io_uring_force_compat = self.io_uring_force_compat,
1868            "Hostcall io_uring bridge dispatch completed"
1869        );
1870    }
1871
1872    const fn advanced_dispatch_enabled(&self) -> bool {
1873        self.dual_exec_config.sample_ppm > 0 || self.io_uring_lane_active()
1874    }
1875
1876    #[inline]
1877    const fn io_uring_lane_active(&self) -> bool {
1878        self.io_uring_lane_config.enabled || self.io_uring_force_compat
1879    }
1880
1881    /// Drain pending hostcall requests from the JS runtime.
1882    #[must_use]
1883    pub fn drain_hostcall_requests(&self) -> VecDeque<HostcallRequest> {
1884        self.js_runtime().drain_hostcall_requests()
1885    }
1886
1887    #[allow(clippy::future_not_send)]
1888    async fn dispatch_hostcall_fast(&self, request: &HostcallRequest) -> HostcallOutcome {
1889        let cap = request.required_capability();
1890        let (check, lookup_path) = self.policy_lookup(cap, request.extension_id.as_deref());
1891        self.emit_policy_decision_telemetry(
1892            cap,
1893            request.extension_id.as_deref(),
1894            lookup_path,
1895            &check,
1896        );
1897        if check.decision != PolicyDecision::Allow {
1898            return HostcallOutcome::Error {
1899                code: "denied".to_string(),
1900                message: format!("Capability '{}' denied by policy ({})", cap, check.reason),
1901            };
1902        }
1903
1904        match &request.kind {
1905            HostcallKind::Tool { name } => {
1906                self.dispatch_tool(&request.call_id, name, request.payload.clone())
1907                    .await
1908            }
1909            HostcallKind::Exec { cmd } => {
1910                self.dispatch_exec_ref(&request.call_id, cmd, &request.payload)
1911                    .await
1912            }
1913            HostcallKind::Http => {
1914                self.dispatch_http(&request.call_id, request.payload.clone())
1915                    .await
1916            }
1917            HostcallKind::Session { op } => {
1918                self.dispatch_session_ref(&request.call_id, op, &request.payload)
1919                    .await
1920            }
1921            HostcallKind::Ui { op } => {
1922                self.dispatch_ui(
1923                    &request.call_id,
1924                    op,
1925                    request.payload.clone(),
1926                    request.extension_id.as_deref(),
1927                )
1928                .await
1929            }
1930            HostcallKind::Events { op } => {
1931                self.dispatch_events_ref(
1932                    &request.call_id,
1933                    request.extension_id.as_deref(),
1934                    op,
1935                    &request.payload,
1936                )
1937                .await
1938            }
1939            HostcallKind::Log => {
1940                tracing::info!(
1941                    target: "pi.extension.log",
1942                    payload = ?request.payload,
1943                    "Extension log"
1944                );
1945                HostcallOutcome::Success(serde_json::json!({ "logged": true }))
1946            }
1947        }
1948    }
1949
1950    #[allow(clippy::future_not_send)]
1951    async fn dispatch_hostcall_io_uring(&self, request: &HostcallRequest) -> IoUringBridgeDispatch {
1952        if !self.js_runtime().is_hostcall_active(&request.call_id) {
1953            return IoUringBridgeDispatch {
1954                outcome: HostcallOutcome::Error {
1955                    code: "cancelled".to_string(),
1956                    message: "Hostcall cancelled before io_uring dispatch".to_string(),
1957                },
1958                state: IoUringBridgeState::CancelledBeforeDispatch,
1959                fallback_reason: Some("cancelled_before_io_uring_dispatch"),
1960            };
1961        }
1962
1963        // io_uring submission/completion wiring is introduced incrementally.
1964        // Keep bridge semantics explicit while delegating execution to the
1965        // existing fast hostcall path until the ring executor lands.
1966        let delegated_outcome = self.dispatch_hostcall_fast(request).await;
1967        if !self.js_runtime().is_hostcall_active(&request.call_id) {
1968            return IoUringBridgeDispatch {
1969                outcome: HostcallOutcome::Error {
1970                    code: "cancelled".to_string(),
1971                    message: "Hostcall cancelled before io_uring completion".to_string(),
1972                },
1973                state: IoUringBridgeState::CancelledAfterDispatch,
1974                fallback_reason: Some("cancelled_before_io_uring_completion"),
1975            };
1976        }
1977
1978        IoUringBridgeDispatch {
1979            outcome: delegated_outcome,
1980            state: IoUringBridgeState::DelegatedFastPath,
1981            fallback_reason: Some("io_uring_bridge_delegated_fast_path"),
1982        }
1983    }
1984
1985    #[allow(clippy::future_not_send)]
1986    async fn dispatch_hostcall_compat_shadow(&self, request: &HostcallRequest) -> HostcallOutcome {
1987        let payload = HostCallPayload {
1988            call_id: request.call_id.clone(),
1989            capability: request.required_capability().to_string(),
1990            method: request.method().to_string(),
1991            params: protocol_params_from_request(request),
1992            timeout_ms: None,
1993            cancel_token: None,
1994            context: None,
1995        };
1996        self.dispatch_protocol_host_call(&payload).await
1997    }
1998
1999    #[allow(clippy::future_not_send)]
2000    async fn run_shadow_dual_exec(
2001        &self,
2002        request: &HostcallRequest,
2003        fast_outcome: &HostcallOutcome,
2004    ) {
2005        let config = self.dual_exec_config;
2006        if config.sample_ppm == 0 {
2007            return;
2008        }
2009
2010        {
2011            let mut state = self.dual_exec_state.borrow_mut();
2012            state.begin_request();
2013            if state.overhead_backoff_remaining > 0 {
2014                return;
2015            }
2016            if !is_shadow_safe_request(request) {
2017                state.skipped_unsupported_total = state.skipped_unsupported_total.saturating_add(1);
2018                return;
2019            }
2020        }
2021
2022        if !should_sample_shadow_dual_exec(request, config.sample_ppm) {
2023            return;
2024        }
2025
2026        let shadow_started_at = Instant::now();
2027        let compat_outcome = self.dispatch_hostcall_compat_shadow(request).await;
2028        let shadow_elapsed_us = shadow_started_at.elapsed().as_secs_f64() * 1_000_000.0;
2029
2030        let diff = diff_hostcall_outcomes(fast_outcome, &compat_outcome);
2031        let rollback_reason = {
2032            let mut state = self.dual_exec_state.borrow_mut();
2033            #[allow(clippy::cast_precision_loss)]
2034            if shadow_elapsed_us > config.overhead_budget_us as f64 {
2035                state.record_overhead_budget_exceeded(config);
2036                tracing::warn!(
2037                    target: "pi.extensions.dual_exec",
2038                    call_id = request.call_id,
2039                    extension_id = %request.extension_id.as_deref().unwrap_or("<none>"),
2040                    method = request.method(),
2041                    shadow_elapsed_us,
2042                    overhead_budget_us = config.overhead_budget_us,
2043                    backoff_requests = state.overhead_backoff_remaining,
2044                    "Shadow dual execution exceeded overhead budget; backoff enabled"
2045                );
2046            }
2047
2048            let divergent = diff.is_some();
2049            state.record_sample(divergent, config, request.extension_id.as_deref())
2050        };
2051
2052        if let Some(diff) = diff {
2053            let forensic_bundle = dual_exec_forensic_bundle(
2054                request,
2055                &diff,
2056                rollback_reason.as_deref(),
2057                shadow_elapsed_us,
2058            );
2059            tracing::warn!(
2060                target: "pi.extensions.dual_exec",
2061                call_id = request.call_id,
2062                extension_id = %request.extension_id.as_deref().unwrap_or("<none>"),
2063                method = request.method(),
2064                rollback_triggered = rollback_reason.is_some(),
2065                rollback_reason = %rollback_reason.as_deref().unwrap_or("none"),
2066                forensic_bundle = %forensic_bundle,
2067                "Shadow dual execution divergence detected"
2068            );
2069        } else {
2070            tracing::trace!(
2071                target: "pi.extensions.dual_exec",
2072                call_id = request.call_id,
2073                extension_id = %request.extension_id.as_deref().unwrap_or("<none>"),
2074                method = request.method(),
2075                shadow_elapsed_us,
2076                "Shadow dual execution matched"
2077            );
2078        }
2079    }
2080
2081    /// Dispatch a hostcall and enqueue its completion into the JS scheduler.
2082    #[allow(clippy::future_not_send, clippy::too_many_lines)]
2083    pub async fn dispatch_and_complete(&self, request: HostcallRequest) {
2084        let cap = request.required_capability();
2085        let (check, lookup_path) = self.policy_lookup(cap, request.extension_id.as_deref());
2086        self.emit_policy_decision_telemetry(
2087            cap,
2088            request.extension_id.as_deref(),
2089            lookup_path,
2090            &check,
2091        );
2092        if check.decision != PolicyDecision::Allow {
2093            let outcome = HostcallOutcome::Error {
2094                code: "denied".to_string(),
2095                message: format!("Capability '{}' denied by policy ({})", cap, check.reason),
2096            };
2097            self.js_runtime()
2098                .complete_hostcall(request.call_id, outcome);
2099            return;
2100        }
2101
2102        if !self.advanced_dispatch_enabled() {
2103            let outcome = self.dispatch_hostcall_fast(&request).await;
2104            self.js_runtime()
2105                .complete_hostcall(request.call_id, outcome);
2106            return;
2107        }
2108
2109        let dispatch_started_at = Instant::now();
2110        let mut queue_depth = 1_usize;
2111        let mut overflow_depth = 0_usize;
2112        let mut overflow_rejected_total = 0_u64;
2113
2114        let (outcome, lane_for_shadow) = if self.io_uring_lane_active() {
2115            let queue_snapshot = self.js_runtime().hostcall_queue_telemetry();
2116            queue_depth = queue_snapshot.total_depth;
2117            overflow_depth = queue_snapshot.overflow_depth;
2118            overflow_rejected_total = queue_snapshot.overflow_rejected_total;
2119
2120            let io_hint = hostcall_io_hint(&request.kind);
2121            let capability_class = HostcallCapabilityClass::from_capability(cap);
2122            let lane_decision = decide_io_uring_lane(
2123                self.io_uring_lane_config,
2124                IoUringLaneDecisionInput {
2125                    capability: capability_class,
2126                    io_hint,
2127                    queue_depth,
2128                    force_compat_lane: self.io_uring_force_compat,
2129                },
2130            );
2131            self.emit_io_uring_lane_telemetry(
2132                &request,
2133                cap,
2134                capability_class,
2135                io_hint,
2136                queue_depth,
2137                lane_decision.lane,
2138                lane_decision.fallback_code(),
2139            );
2140
2141            let outcome = match lane_decision.lane {
2142                HostcallDispatchLane::Fast => self.dispatch_hostcall_fast(&request).await,
2143                HostcallDispatchLane::IoUring => {
2144                    let bridge_dispatch = self.dispatch_hostcall_io_uring(&request).await;
2145                    self.emit_io_uring_bridge_telemetry(
2146                        &request,
2147                        bridge_dispatch.state,
2148                        bridge_dispatch.fallback_reason,
2149                    );
2150                    bridge_dispatch.outcome
2151                }
2152                HostcallDispatchLane::Compat => {
2153                    self.dispatch_hostcall_compat_shadow(&request).await
2154                }
2155            };
2156            (outcome, lane_decision.lane)
2157        } else {
2158            (
2159                self.dispatch_hostcall_fast(&request).await,
2160                HostcallDispatchLane::Fast,
2161            )
2162        };
2163
2164        if lane_for_shadow != HostcallDispatchLane::Compat {
2165            self.run_shadow_dual_exec(&request, &outcome).await;
2166        }
2167
2168        let service_time_us = dispatch_started_at.elapsed().as_secs_f64() * 1_000_000.0;
2169        let opcode_entropy = hostcall_opcode_entropy(&request.kind, &request.payload);
2170        let llc_miss_rate = llc_miss_proxy(queue_depth, overflow_depth, overflow_rejected_total);
2171        let regime_signal = RegimeSignal {
2172            queue_depth: usize_to_f64(queue_depth),
2173            service_time_us,
2174            opcode_entropy,
2175            llc_miss_rate,
2176        };
2177        let observation = {
2178            let mut detector = self.regime_detector.borrow_mut();
2179            detector.observe(regime_signal)
2180        };
2181        Self::emit_regime_observation_telemetry(
2182            &request.call_id,
2183            observation,
2184            queue_depth,
2185            overflow_depth,
2186            overflow_rejected_total,
2187            service_time_us,
2188        );
2189
2190        self.js_runtime()
2191            .complete_hostcall(request.call_id, outcome);
2192    }
2193
2194    /// Dispatch a batch of hostcall requests using AMAC-aware grouping.
2195    ///
2196    /// Groups requests by kind, decides per-group whether to interleave or
2197    /// use sequential dispatch, then dispatches accordingly. Falls back to
2198    /// sequential one-by-one dispatch when AMAC is disabled or the batch is
2199    /// too small.
2200    #[allow(clippy::future_not_send)]
2201    pub async fn dispatch_batch_amac(&self, mut requests: VecDeque<HostcallRequest>) {
2202        if requests.is_empty() {
2203            return;
2204        }
2205
2206        let (rollback_active, rollback_remaining, rollback_reason) = {
2207            let state = self.dual_exec_state.borrow();
2208            (
2209                state.rollback_active(),
2210                state.rollback_remaining,
2211                state
2212                    .rollback_reason
2213                    .clone()
2214                    .unwrap_or_else(|| "dual_exec_rollback_active".to_string()),
2215            )
2216        };
2217
2218        // Check if AMAC is enabled before consuming requests.
2219        let amac_enabled = self.amac_executor.borrow().enabled();
2220        let adaptation_mode = self.regime_detector.borrow().current_mode();
2221        let rollout_forces_sequential = adaptation_mode == RegimeAdaptationMode::SequentialFastPath;
2222        if !amac_enabled || rollback_active || rollout_forces_sequential {
2223            if rollback_active {
2224                tracing::warn!(
2225                    target: "pi.extensions.dual_exec",
2226                    rollback_remaining,
2227                    rollback_reason = %rollback_reason,
2228                    "Dual-exec rollback forcing sequential dispatcher mode"
2229                );
2230            } else if rollout_forces_sequential && amac_enabled {
2231                tracing::debug!(
2232                    target: "pi.extensions.regime_shift",
2233                    adaptation_mode = adaptation_mode.as_str(),
2234                    "Rollout gate forcing sequential dispatch mode"
2235                );
2236            }
2237            // Dispatch sequentially without AMAC overhead.
2238            while let Some(req) = requests.pop_front() {
2239                self.dispatch_and_complete(req).await;
2240            }
2241            return;
2242        }
2243
2244        let request_vec: Vec<HostcallRequest> = requests.into();
2245        let plan = self.amac_executor.borrow_mut().plan_batch(request_vec);
2246
2247        for (group, decision) in plan.groups.into_iter().zip(plan.decisions.iter()) {
2248            let group_key = group.key.clone();
2249            let start = Instant::now();
2250            // Dispatch each request in the group sequentially.
2251            // AMAC decision metadata is recorded for telemetry but the
2252            // actual dispatch remains sequential within a single-threaded
2253            // async executor — true concurrency is achieved at the reactor
2254            // mesh level (bd-3ar8v.4.20).
2255            for request in group.requests {
2256                let req_start = Instant::now();
2257                self.dispatch_and_complete(request).await;
2258                let elapsed_ns = u64::try_from(req_start.elapsed().as_nanos()).unwrap_or(u64::MAX);
2259                self.amac_executor.borrow_mut().observe_call(elapsed_ns);
2260            }
2261
2262            let group_elapsed_ns = u64::try_from(start.elapsed().as_nanos()).unwrap_or(u64::MAX);
2263            tracing::trace!(
2264                target: "pi.extensions.amac",
2265                group_key = ?group_key,
2266                decision = ?decision,
2267                group_elapsed_ns,
2268                "AMAC group dispatched"
2269            );
2270        }
2271    }
2272
2273    /// Protocol adapter: convert `ExtensionMessage(type=host_call)` into
2274    /// `ExtensionMessage(type=host_result)` using the same dispatch paths used
2275    /// by runtime hostcalls.
2276    #[allow(clippy::future_not_send)]
2277    pub async fn dispatch_protocol_message(
2278        &self,
2279        message: ExtensionMessage,
2280    ) -> Result<ExtensionMessage> {
2281        let ExtensionMessage { id, version, body } = message;
2282        if id.trim().is_empty() {
2283            return Err(crate::error::Error::validation(
2284                "Extension message id is empty",
2285            ));
2286        }
2287        if version != PROTOCOL_VERSION {
2288            return Err(crate::error::Error::validation(format!(
2289                "Unsupported extension protocol version: {version}"
2290            )));
2291        }
2292        let ExtensionBody::HostCall(payload) = body else {
2293            return Err(crate::error::Error::validation(
2294                "dispatch_protocol_message expects host_call message",
2295            ));
2296        };
2297
2298        let outcome = match validate_host_call(&payload) {
2299            Ok(()) => self.dispatch_protocol_host_call(&payload).await,
2300            Err(crate::error::Error::Validation(message)) => {
2301                if payload.call_id.trim().is_empty() {
2302                    return Err(crate::error::Error::Validation(message));
2303                }
2304                HostcallOutcome::Error {
2305                    code: "invalid_request".to_string(),
2306                    message,
2307                }
2308            }
2309            Err(err) => return Err(err),
2310        };
2311        let response = ExtensionMessage {
2312            id,
2313            version,
2314            body: ExtensionBody::HostResult(hostcall_outcome_to_protocol_result_with_trace(
2315                &payload, outcome,
2316            )),
2317        };
2318        response.validate()?;
2319        Ok(response)
2320    }
2321
2322    #[allow(clippy::future_not_send, clippy::too_many_lines)]
2323    async fn dispatch_protocol_host_call(&self, payload: &HostCallPayload) -> HostcallOutcome {
2324        if let Some(cap) = required_capability_for_host_call_static(payload) {
2325            let (check, lookup_path) = self.policy_lookup(cap, None);
2326            self.emit_policy_decision_telemetry(cap, None, lookup_path, &check);
2327            if check.decision != PolicyDecision::Allow {
2328                return HostcallOutcome::Error {
2329                    code: "denied".to_string(),
2330                    message: format!("Capability '{}' denied by policy ({})", cap, check.reason),
2331                };
2332            }
2333        }
2334
2335        let method = payload.method.trim();
2336
2337        match parse_protocol_hostcall_method(method) {
2338            Some(ProtocolHostcallMethod::Tool) => {
2339                let Some(name) = payload
2340                    .params
2341                    .get("name")
2342                    .and_then(Value::as_str)
2343                    .map(str::trim)
2344                    .filter(|name| !name.is_empty())
2345                else {
2346                    return HostcallOutcome::Error {
2347                        code: "invalid_request".to_string(),
2348                        message: "host_call tool requires params.name".to_string(),
2349                    };
2350                };
2351                let input = payload
2352                    .params
2353                    .get("input")
2354                    .cloned()
2355                    .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
2356                self.dispatch_tool(&payload.call_id, name, input).await
2357            }
2358            Some(ProtocolHostcallMethod::Exec) => {
2359                let Some(cmd) = payload
2360                    .params
2361                    .get("cmd")
2362                    .or_else(|| payload.params.get("command"))
2363                    .and_then(Value::as_str)
2364                    .map(str::trim)
2365                    .filter(|cmd| !cmd.is_empty())
2366                else {
2367                    return HostcallOutcome::Error {
2368                        code: "invalid_request".to_string(),
2369                        message: "host_call exec requires params.cmd or params.command".to_string(),
2370                    };
2371                };
2372
2373                // SEC-4.3: Exec mediation — classify and gate dangerous commands.
2374                let args: Vec<String> = payload
2375                    .params
2376                    .get("args")
2377                    .and_then(Value::as_array)
2378                    .map(|arr| {
2379                        arr.iter()
2380                            .filter_map(|v| v.as_str().map(ToString::to_string))
2381                            .collect()
2382                    })
2383                    .unwrap_or_default();
2384                let mediation = evaluate_exec_mediation(&self.policy.exec_mediation, cmd, &args);
2385                match &mediation {
2386                    ExecMediationResult::Deny { class, reason } => {
2387                        tracing::warn!(
2388                            event = "exec.mediation.deny",
2389                            command_class = ?class.map(DangerousCommandClass::label),
2390                            reason = %reason,
2391                            "Exec command denied by mediation policy"
2392                        );
2393                        return HostcallOutcome::Error {
2394                            code: "denied".to_string(),
2395                            message: format!("Exec denied by mediation policy: {reason}"),
2396                        };
2397                    }
2398                    ExecMediationResult::AllowWithAudit { class, reason } => {
2399                        tracing::info!(
2400                            event = "exec.mediation.audit",
2401                            command_class = class.label(),
2402                            reason = %reason,
2403                            "Exec command allowed with audit"
2404                        );
2405                    }
2406                    ExecMediationResult::Allow => {}
2407                }
2408
2409                self.dispatch_exec_ref(&payload.call_id, cmd, &payload.params)
2410                    .await
2411            }
2412            Some(ProtocolHostcallMethod::Http) => {
2413                self.dispatch_http(&payload.call_id, payload.params.clone())
2414                    .await
2415            }
2416            Some(ProtocolHostcallMethod::Session) => {
2417                let Some(op) = protocol_hostcall_op(&payload.params) else {
2418                    return HostcallOutcome::Error {
2419                        code: "invalid_request".to_string(),
2420                        message: "host_call session requires params.op".to_string(),
2421                    };
2422                };
2423                self.dispatch_session_ref(&payload.call_id, op, &payload.params)
2424                    .await
2425            }
2426            Some(ProtocolHostcallMethod::Ui) => {
2427                let Some(op) = protocol_hostcall_op(&payload.params) else {
2428                    return HostcallOutcome::Error {
2429                        code: "invalid_request".to_string(),
2430                        message: "host_call ui requires params.op".to_string(),
2431                    };
2432                };
2433                self.dispatch_ui(&payload.call_id, op, payload.params.clone(), None)
2434                    .await
2435            }
2436            Some(ProtocolHostcallMethod::Events) => {
2437                let Some(op) = protocol_hostcall_op(&payload.params) else {
2438                    return HostcallOutcome::Error {
2439                        code: "invalid_request".to_string(),
2440                        message: "host_call events requires params.op".to_string(),
2441                    };
2442                };
2443                self.dispatch_events_ref(&payload.call_id, None, op, &payload.params)
2444                    .await
2445            }
2446            Some(ProtocolHostcallMethod::Log) => {
2447                tracing::info!(
2448                    target: "pi.extension.log",
2449                    payload = ?payload.params,
2450                    "Extension log"
2451                );
2452                HostcallOutcome::Success(serde_json::json!({ "logged": true }))
2453            }
2454            None => HostcallOutcome::Error {
2455                code: "invalid_request".to_string(),
2456                message: format!("Unsupported host_call method: {method}"),
2457            },
2458        }
2459    }
2460
2461    #[allow(clippy::future_not_send)]
2462    async fn dispatch_tool(
2463        &self,
2464        call_id: &str,
2465        name: &str,
2466        payload: serde_json::Value,
2467    ) -> HostcallOutcome {
2468        let Some(tool) = self.tool_registry.get(name) else {
2469            return HostcallOutcome::Error {
2470                code: "invalid_request".to_string(),
2471                message: format!("Unknown tool: {name}"),
2472            };
2473        };
2474
2475        match tool.execute(call_id, payload, None).await {
2476            Ok(output) => match serde_json::to_value(output) {
2477                Ok(value) => HostcallOutcome::Success(value),
2478                Err(err) => HostcallOutcome::Error {
2479                    code: "internal".to_string(),
2480                    message: format!("Serialize tool output: {err}"),
2481                },
2482            },
2483            Err(err) => HostcallOutcome::Error {
2484                code: "io".to_string(),
2485                message: err.to_string(),
2486            },
2487        }
2488    }
2489
2490    #[allow(clippy::future_not_send)]
2491    async fn dispatch_exec(
2492        &self,
2493        call_id: &str,
2494        cmd: &str,
2495        payload: serde_json::Value,
2496    ) -> HostcallOutcome {
2497        self.dispatch_exec_ref(call_id, cmd, &payload).await
2498    }
2499
2500    #[allow(clippy::future_not_send, clippy::too_many_lines)]
2501    async fn dispatch_exec_ref(
2502        &self,
2503        call_id: &str,
2504        cmd: &str,
2505        payload: &serde_json::Value,
2506    ) -> HostcallOutcome {
2507        use std::process::{Command, Stdio};
2508        use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
2509        use std::sync::mpsc;
2510
2511        enum ExecStreamFrame {
2512            Stdout(String),
2513            Stderr(String),
2514            Final { code: i32, killed: bool },
2515            Error(String),
2516        }
2517
2518        fn pump_stream<R: std::io::Read>(
2519            mut reader: R,
2520            tx: &std::sync::mpsc::SyncSender<ExecStreamFrame>,
2521            stdout: bool,
2522        ) -> std::result::Result<(), String> {
2523            let mut buf = [0u8; 4096];
2524            let mut partial = Vec::new();
2525
2526            loop {
2527                let read = match reader.read(&mut buf) {
2528                    Ok(0) => 0,
2529                    Ok(n) => n,
2530                    Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
2531                    Err(err) => return Err(err.to_string()),
2532                };
2533                if read == 0 {
2534                    // EOF. Flush partial if any (lossy).
2535                    if !partial.is_empty() {
2536                        let text = String::from_utf8_lossy(&partial).to_string();
2537                        let frame = if stdout {
2538                            ExecStreamFrame::Stdout(text)
2539                        } else {
2540                            ExecStreamFrame::Stderr(text)
2541                        };
2542                        let _ = tx.send(frame);
2543                    }
2544                    break;
2545                }
2546
2547                let chunk = &buf[..read];
2548
2549                // If we have partial data, we must append the new chunk and process the combined buffer.
2550                // If partial is empty, we can process the chunk directly (fast path).
2551                if partial.is_empty() {
2552                    let mut processed = 0;
2553                    loop {
2554                        match std::str::from_utf8(&chunk[processed..]) {
2555                            Ok(s) => {
2556                                if !s.is_empty() {
2557                                    let frame = if stdout {
2558                                        ExecStreamFrame::Stdout(s.to_string())
2559                                    } else {
2560                                        ExecStreamFrame::Stderr(s.to_string())
2561                                    };
2562                                    if tx.send(frame).is_err() {
2563                                        return Ok(());
2564                                    }
2565                                }
2566                                break;
2567                            }
2568                            Err(e) => {
2569                                let valid_len = e.valid_up_to();
2570                                if valid_len > 0 {
2571                                    let s = std::str::from_utf8(
2572                                        &chunk[processed..processed + valid_len],
2573                                    )
2574                                    .expect("valid utf8 prefix");
2575                                    let frame = if stdout {
2576                                        ExecStreamFrame::Stdout(s.to_string())
2577                                    } else {
2578                                        ExecStreamFrame::Stderr(s.to_string())
2579                                    };
2580                                    if tx.send(frame).is_err() {
2581                                        return Ok(());
2582                                    }
2583                                    processed += valid_len;
2584                                }
2585
2586                                if let Some(len) = e.error_len() {
2587                                    // Invalid sequence: emit replacement and skip
2588                                    let frame = if stdout {
2589                                        ExecStreamFrame::Stdout("\u{FFFD}".to_string())
2590                                    } else {
2591                                        ExecStreamFrame::Stderr("\u{FFFD}".to_string())
2592                                    };
2593                                    if tx.send(frame).is_err() {
2594                                        return Ok(());
2595                                    }
2596                                    processed += len;
2597                                } else {
2598                                    // Incomplete at end: buffer the remainder
2599                                    partial.extend_from_slice(&chunk[processed..]);
2600                                    break;
2601                                }
2602                            }
2603                        }
2604                    }
2605                } else {
2606                    partial.extend_from_slice(chunk);
2607                    let mut processed = 0;
2608                    loop {
2609                        match std::str::from_utf8(&partial[processed..]) {
2610                            Ok(s) => {
2611                                if !s.is_empty() {
2612                                    let frame = if stdout {
2613                                        ExecStreamFrame::Stdout(s.to_string())
2614                                    } else {
2615                                        ExecStreamFrame::Stderr(s.to_string())
2616                                    };
2617                                    if tx.send(frame).is_err() {
2618                                        return Ok(());
2619                                    }
2620                                }
2621                                partial.clear();
2622                                break;
2623                            }
2624                            Err(e) => {
2625                                let valid_len = e.valid_up_to();
2626                                if valid_len > 0 {
2627                                    let s = std::str::from_utf8(
2628                                        &partial[processed..processed + valid_len],
2629                                    )
2630                                    .expect("valid utf8 prefix");
2631                                    let frame = if stdout {
2632                                        ExecStreamFrame::Stdout(s.to_string())
2633                                    } else {
2634                                        ExecStreamFrame::Stderr(s.to_string())
2635                                    };
2636                                    if tx.send(frame).is_err() {
2637                                        return Ok(());
2638                                    }
2639                                    processed += valid_len;
2640                                }
2641
2642                                if let Some(len) = e.error_len() {
2643                                    // Invalid sequence
2644                                    let frame = if stdout {
2645                                        ExecStreamFrame::Stdout("\u{FFFD}".to_string())
2646                                    } else {
2647                                        ExecStreamFrame::Stderr("\u{FFFD}".to_string())
2648                                    };
2649                                    if tx.send(frame).is_err() {
2650                                        return Ok(());
2651                                    }
2652                                    processed += len;
2653                                } else {
2654                                    // Incomplete at end
2655                                    // Move remaining bytes to start of partial
2656                                    let remaining = partial.len() - processed;
2657                                    partial.copy_within(processed.., 0);
2658                                    partial.truncate(remaining);
2659                                    break;
2660                                }
2661                            }
2662                        }
2663                    }
2664                }
2665            }
2666            Ok(())
2667        }
2668
2669        #[allow(clippy::unnecessary_lazy_evaluations)] // lazy eval needed on unix for signal()
2670        fn exit_status_code(status: std::process::ExitStatus) -> i32 {
2671            status.code().unwrap_or_else(|| {
2672                #[cfg(unix)]
2673                {
2674                    use std::os::unix::process::ExitStatusExt as _;
2675                    status.signal().map_or(-1, |signal| -signal)
2676                }
2677                #[cfg(not(unix))]
2678                {
2679                    -1
2680                }
2681            })
2682        }
2683
2684        let args = match payload.get("args") {
2685            None | Some(serde_json::Value::Null) => Vec::new(),
2686            Some(serde_json::Value::Array(items)) => items
2687                .iter()
2688                .map(|value| {
2689                    value
2690                        .as_str()
2691                        .map_or_else(|| value.to_string(), ToString::to_string)
2692                })
2693                .collect::<Vec<_>>(),
2694            Some(_) => {
2695                return HostcallOutcome::Error {
2696                    code: "invalid_request".to_string(),
2697                    message: "exec args must be an array".to_string(),
2698                };
2699            }
2700        };
2701
2702        let options = payload
2703            .get("options")
2704            .and_then(serde_json::Value::as_object);
2705        let cwd = options
2706            .and_then(|opts| opts.get("cwd"))
2707            .and_then(serde_json::Value::as_str)
2708            .map_or_else(|| self.cwd.clone(), PathBuf::from);
2709        let timeout_ms = options
2710            .and_then(|opts| {
2711                opts.get("timeout")
2712                    .and_then(serde_json::Value::as_u64)
2713                    .or_else(|| opts.get("timeoutMs").and_then(serde_json::Value::as_u64))
2714                    .or_else(|| opts.get("timeout_ms").and_then(serde_json::Value::as_u64))
2715            })
2716            .filter(|ms| *ms > 0);
2717        let stream = options
2718            .and_then(|opts| opts.get("stream"))
2719            .and_then(serde_json::Value::as_bool)
2720            .unwrap_or(false);
2721
2722        if stream {
2723            struct CancelGuard(Arc<AtomicBool>);
2724            impl Drop for CancelGuard {
2725                fn drop(&mut self) {
2726                    self.0.store(true, AtomicOrdering::SeqCst);
2727                }
2728            }
2729
2730            let cmd = cmd.to_string();
2731            let args = args.clone();
2732            let (tx, rx) = mpsc::sync_channel::<ExecStreamFrame>(1024);
2733            let cancel = Arc::new(AtomicBool::new(false));
2734            let cancel_worker = Arc::clone(&cancel);
2735            let call_id_for_error = call_id.to_string();
2736
2737            thread::spawn(move || {
2738                let result = (|| -> std::result::Result<(), String> {
2739                    let mut command = Command::new(&cmd);
2740                    command
2741                        .args(&args)
2742                        .stdin(Stdio::null())
2743                        .stdout(Stdio::piped())
2744                        .stderr(Stdio::piped())
2745                        .current_dir(&cwd);
2746                    crate::tools::isolate_command_process_group(&mut command);
2747
2748                    let mut child = command.spawn().map_err(|err| err.to_string())?;
2749                    let pid = child.id();
2750
2751                    let stdout = child.stdout.take().ok_or("Missing stdout pipe")?;
2752                    let stderr = child.stderr.take().ok_or("Missing stderr pipe")?;
2753
2754                    let stdout_tx = tx.clone();
2755                    let stderr_tx = tx.clone();
2756                    let stdout_handle =
2757                        thread::spawn(move || pump_stream(stdout, &stdout_tx, true));
2758                    let stderr_handle =
2759                        thread::spawn(move || pump_stream(stderr, &stderr_tx, false));
2760
2761                    let start = Instant::now();
2762                    let mut killed = false;
2763                    let status = loop {
2764                        if let Some(status) = child.try_wait().map_err(|err| err.to_string())? {
2765                            break status;
2766                        }
2767
2768                        if !killed && cancel_worker.load(AtomicOrdering::SeqCst) {
2769                            killed = true;
2770                            crate::tools::kill_process_group_tree(Some(pid));
2771                            let _ = child.kill();
2772                            break child.wait().map_err(|err| err.to_string())?;
2773                        }
2774
2775                        if let Some(timeout_ms) = timeout_ms {
2776                            if !killed && start.elapsed() >= Duration::from_millis(timeout_ms) {
2777                                killed = true;
2778                                crate::tools::kill_process_group_tree(Some(pid));
2779                                let _ = child.kill();
2780                                break child.wait().map_err(|err| err.to_string())?;
2781                            }
2782                        }
2783
2784                        thread::sleep(Duration::from_millis(10));
2785                    };
2786
2787                    let drain_start = Instant::now();
2788                    let drain_deadline = drain_start + Duration::from_secs(5);
2789                    loop {
2790                        if stdout_handle.is_finished() && stderr_handle.is_finished() {
2791                            break;
2792                        }
2793                        if Instant::now() >= drain_deadline {
2794                            break;
2795                        }
2796                        thread::sleep(Duration::from_millis(10));
2797                    }
2798
2799                    // Explicitly reap to avoid leaving a zombie behind after a
2800                    // successful try_wait()-observed exit on isolated process groups.
2801                    let _ = child.wait();
2802
2803                    let code = exit_status_code(status);
2804                    let _ = tx.send(ExecStreamFrame::Final { code, killed });
2805                    Ok(())
2806                })();
2807
2808                if let Err(err) = result {
2809                    if tx.send(ExecStreamFrame::Error(err)).is_err() {
2810                        tracing::trace!(
2811                            call_id = %call_id_for_error,
2812                            "Exec hostcall stream result dropped before completion"
2813                        );
2814                    }
2815                }
2816            });
2817
2818            let _guard = CancelGuard(Arc::clone(&cancel));
2819
2820            let mut sequence = 0_u64;
2821            let mut processed_in_turn = 0_u32;
2822            loop {
2823                if !self.js_runtime().is_hostcall_active(call_id) {
2824                    cancel.store(true, AtomicOrdering::SeqCst);
2825                    return HostcallOutcome::StreamChunk {
2826                        sequence,
2827                        chunk: serde_json::Value::Null,
2828                        is_final: false,
2829                    };
2830                }
2831
2832                match rx.try_recv() {
2833                    Ok(ExecStreamFrame::Stdout(chunk)) => {
2834                        self.js_runtime().complete_hostcall(
2835                            call_id.to_string(),
2836                            HostcallOutcome::StreamChunk {
2837                                sequence,
2838                                chunk: serde_json::json!({ "stdout": chunk }),
2839                                is_final: false,
2840                            },
2841                        );
2842                        sequence = sequence.saturating_add(1);
2843                        processed_in_turn += 1;
2844                    }
2845                    Ok(ExecStreamFrame::Stderr(chunk)) => {
2846                        self.js_runtime().complete_hostcall(
2847                            call_id.to_string(),
2848                            HostcallOutcome::StreamChunk {
2849                                sequence,
2850                                chunk: serde_json::json!({ "stderr": chunk }),
2851                                is_final: false,
2852                            },
2853                        );
2854                        sequence = sequence.saturating_add(1);
2855                        processed_in_turn += 1;
2856                    }
2857                    Ok(ExecStreamFrame::Final { code, killed }) => {
2858                        return HostcallOutcome::StreamChunk {
2859                            sequence,
2860                            chunk: serde_json::json!({
2861                                "code": code,
2862                                "killed": killed,
2863                            }),
2864                            is_final: true,
2865                        };
2866                    }
2867                    Ok(ExecStreamFrame::Error(message)) => {
2868                        return HostcallOutcome::Error {
2869                            code: "io".to_string(),
2870                            message,
2871                        };
2872                    }
2873                    Err(mpsc::TryRecvError::Empty) => {
2874                        processed_in_turn = 0;
2875                        extension_wait_sleep(Duration::from_millis(25)).await;
2876                    }
2877                    Err(mpsc::TryRecvError::Disconnected) => {
2878                        return HostcallOutcome::Error {
2879                            code: "internal".to_string(),
2880                            message: "exec stream channel closed".to_string(),
2881                        };
2882                    }
2883                }
2884
2885                if processed_in_turn >= 64 {
2886                    processed_in_turn = 0;
2887                    asupersync::runtime::yield_now().await;
2888                }
2889            }
2890        }
2891
2892        let cmd = cmd.to_string();
2893        let args = args.clone();
2894        let (tx, rx) = mpsc::sync_channel::<std::result::Result<serde_json::Value, String>>(1);
2895        let cancel = Arc::new(AtomicBool::new(false));
2896        let cancel_worker = Arc::clone(&cancel);
2897        let call_id_for_error = call_id.to_string();
2898
2899        thread::spawn(move || {
2900            #[derive(Clone, Copy)]
2901            enum StreamKind {
2902                Stdout,
2903                Stderr,
2904            }
2905
2906            struct StreamChunk {
2907                kind: StreamKind,
2908                bytes: Vec<u8>,
2909            }
2910
2911            fn pump_stream(
2912                mut reader: impl std::io::Read,
2913                tx: &std::sync::mpsc::SyncSender<StreamChunk>,
2914                kind: StreamKind,
2915            ) {
2916                let mut buf = [0u8; 8192];
2917                loop {
2918                    let read = match reader.read(&mut buf) {
2919                        Ok(0) => break,
2920                        Ok(read) => read,
2921                        Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
2922                        Err(_) => break,
2923                    };
2924                    let chunk = StreamChunk {
2925                        kind,
2926                        bytes: buf[..read].to_vec(),
2927                    };
2928                    if tx.send(chunk).is_err() {
2929                        break;
2930                    }
2931                }
2932            }
2933
2934            let result: std::result::Result<serde_json::Value, String> = (|| {
2935                let mut command = Command::new(&cmd);
2936                command
2937                    .args(&args)
2938                    .stdin(Stdio::null())
2939                    .stdout(Stdio::piped())
2940                    .stderr(Stdio::piped())
2941                    .current_dir(&cwd);
2942                crate::tools::isolate_command_process_group(&mut command);
2943
2944                let mut child = command.spawn().map_err(|err| err.to_string())?;
2945                let pid = child.id();
2946
2947                let stdout = child.stdout.take().ok_or("Missing stdout pipe")?;
2948                let stderr = child.stderr.take().ok_or("Missing stderr pipe")?;
2949
2950                let (tx, rx) = std::sync::mpsc::sync_channel::<StreamChunk>(1024);
2951                let tx_stdout = tx.clone();
2952                let _stdout_handle =
2953                    thread::spawn(move || pump_stream(stdout, &tx_stdout, StreamKind::Stdout));
2954                let _stderr_handle =
2955                    thread::spawn(move || pump_stream(stderr, &tx, StreamKind::Stderr));
2956
2957                let start = Instant::now();
2958                let mut killed = false;
2959                let max_bytes = crate::tools::DEFAULT_MAX_BYTES.saturating_mul(2);
2960
2961                let mut stdout_chunks = std::collections::VecDeque::new();
2962                let mut stderr_chunks = std::collections::VecDeque::new();
2963                let mut stdout_bytes_len = 0usize;
2964                let mut stderr_bytes_len = 0usize;
2965
2966                let mut ingest_chunk = |kind: StreamKind, bytes: Vec<u8>| match kind {
2967                    StreamKind::Stdout => {
2968                        stdout_bytes_len += bytes.len();
2969                        stdout_chunks.push_back(bytes);
2970                        while stdout_bytes_len > max_bytes && stdout_chunks.len() > 1 {
2971                            if let Some(front) = stdout_chunks.pop_front() {
2972                                stdout_bytes_len -= front.len();
2973                            }
2974                        }
2975                    }
2976                    StreamKind::Stderr => {
2977                        stderr_bytes_len += bytes.len();
2978                        stderr_chunks.push_back(bytes);
2979                        while stderr_bytes_len > max_bytes && stderr_chunks.len() > 1 {
2980                            if let Some(front) = stderr_chunks.pop_front() {
2981                                stderr_bytes_len -= front.len();
2982                            }
2983                        }
2984                    }
2985                };
2986
2987                let status = loop {
2988                    // Drain available
2989                    while let Ok(chunk) = rx.try_recv() {
2990                        ingest_chunk(chunk.kind, chunk.bytes);
2991                    }
2992
2993                    if let Some(status) = child.try_wait().map_err(|err| err.to_string())? {
2994                        break status;
2995                    }
2996
2997                    if !killed && cancel_worker.load(AtomicOrdering::SeqCst) {
2998                        killed = true;
2999                        crate::tools::kill_process_group_tree(Some(pid));
3000                        let _ = child.kill();
3001                        break child.wait().map_err(|err| err.to_string())?;
3002                    }
3003
3004                    if let Some(timeout_ms) = timeout_ms {
3005                        if !killed && start.elapsed() >= Duration::from_millis(timeout_ms) {
3006                            killed = true;
3007                            crate::tools::kill_process_group_tree(Some(pid));
3008                            let _ = child.kill();
3009                            break child.wait().map_err(|err| err.to_string())?;
3010                        }
3011                    }
3012
3013                    if let Ok(chunk) = rx.recv_timeout(Duration::from_millis(10)) {
3014                        ingest_chunk(chunk.kind, chunk.bytes);
3015                    }
3016                };
3017
3018                let drain_deadline = Instant::now() + Duration::from_secs(2);
3019                loop {
3020                    match rx.try_recv() {
3021                        Ok(chunk) => ingest_chunk(chunk.kind, chunk.bytes),
3022                        Err(std::sync::mpsc::TryRecvError::Empty) => {
3023                            if Instant::now() >= drain_deadline {
3024                                break;
3025                            }
3026                            thread::sleep(Duration::from_millis(10));
3027                        }
3028                        Err(std::sync::mpsc::TryRecvError::Disconnected) => break,
3029                    }
3030                }
3031
3032                drop(rx); // Close the channel so pump threads exit if blocked
3033
3034                // Explicitly reap the child to prevent zombies on macOS.
3035                // try_wait() uses WNOHANG which may not fully reap when the
3036                // child is in its own process group.
3037                let _ = child.wait();
3038
3039                let stdout_bytes: Vec<u8> = stdout_chunks.into_iter().flatten().collect();
3040                let stderr_bytes: Vec<u8> = stderr_chunks.into_iter().flatten().collect();
3041
3042                let stdout = String::from_utf8_lossy(&stdout_bytes).to_string();
3043                let stderr = String::from_utf8_lossy(&stderr_bytes).to_string();
3044                let code = exit_status_code(status);
3045
3046                Ok(serde_json::json!({
3047                    "stdout": stdout,
3048                    "stderr": stderr,
3049                    "code": code,
3050                    "killed": killed,
3051                }))
3052            })();
3053
3054            if tx.send(result).is_err() {
3055                tracing::trace!(
3056                    call_id = %call_id_for_error,
3057                    "Exec hostcall result dropped before completion"
3058                );
3059            }
3060        });
3061
3062        let _guard = CancelGuard(Arc::clone(&cancel));
3063
3064        loop {
3065            if !self.js_runtime().is_hostcall_active(call_id) {
3066                cancel.store(true, AtomicOrdering::SeqCst);
3067                return HostcallOutcome::Error {
3068                    code: "internal".to_string(),
3069                    message: "exec task cancelled".to_string(),
3070                };
3071            }
3072
3073            match rx.try_recv() {
3074                Ok(Ok(value)) => return HostcallOutcome::Success(value),
3075                Ok(Err(err)) => {
3076                    return HostcallOutcome::Error {
3077                        code: "io".to_string(),
3078                        message: err,
3079                    };
3080                }
3081                Err(mpsc::TryRecvError::Empty) => {
3082                    extension_wait_sleep(Duration::from_millis(25)).await;
3083                }
3084                Err(mpsc::TryRecvError::Disconnected) => {
3085                    return HostcallOutcome::Error {
3086                        code: "internal".to_string(),
3087                        message: "exec task cancelled".to_string(),
3088                    };
3089                }
3090            }
3091        }
3092    }
3093
3094    #[allow(clippy::future_not_send)]
3095    async fn dispatch_http(&self, call_id: &str, payload: serde_json::Value) -> HostcallOutcome {
3096        let call = HostCallPayload {
3097            call_id: call_id.to_string(),
3098            capability: "http".to_string(),
3099            method: "http".to_string(),
3100            params: payload,
3101            timeout_ms: None,
3102            cancel_token: None,
3103            context: None,
3104        };
3105
3106        match self.http_connector.dispatch(&call).await {
3107            Ok(result) => {
3108                if result.is_error {
3109                    let message = result.error.as_ref().map_or_else(
3110                        || "HTTP connector error".to_string(),
3111                        |err| err.message.clone(),
3112                    );
3113                    let code = result
3114                        .error
3115                        .as_ref()
3116                        .map_or("internal", |err| hostcall_code_to_str(err.code));
3117                    HostcallOutcome::Error {
3118                        code: code.to_string(),
3119                        message,
3120                    }
3121                } else {
3122                    HostcallOutcome::Success(result.output)
3123                }
3124            }
3125            Err(err) => HostcallOutcome::Error {
3126                code: "internal".to_string(),
3127                message: err.to_string(),
3128            },
3129        }
3130    }
3131
3132    #[allow(clippy::future_not_send)]
3133    async fn dispatch_session(&self, call_id: &str, op: &str, payload: Value) -> HostcallOutcome {
3134        self.dispatch_session_ref(call_id, op, &payload).await
3135    }
3136
3137    #[allow(clippy::future_not_send, clippy::too_many_lines)]
3138    async fn dispatch_session_ref(
3139        &self,
3140        _call_id: &str,
3141        op: &str,
3142        payload: &Value,
3143    ) -> HostcallOutcome {
3144        use crate::connectors::HostCallErrorCode;
3145
3146        let op_norm = op.trim().to_ascii_lowercase();
3147
3148        // Categorised result: (Value, error_code) where error_code distinguishes taxonomy.
3149        let result: std::result::Result<Value, (HostCallErrorCode, String)> = match op_norm.as_str()
3150        {
3151            "get_state" | "getstate" => Ok(self.session.get_state().await),
3152            "get_messages" | "getmessages" => {
3153                serde_json::to_value(self.session.get_messages().await).map_err(|err| {
3154                    (
3155                        HostCallErrorCode::Internal,
3156                        format!("Serialize messages: {err}"),
3157                    )
3158                })
3159            }
3160            "get_entries" | "getentries" => serde_json::to_value(self.session.get_entries().await)
3161                .map_err(|err| {
3162                    (
3163                        HostCallErrorCode::Internal,
3164                        format!("Serialize entries: {err}"),
3165                    )
3166                }),
3167            "get_branch" | "getbranch" => serde_json::to_value(self.session.get_branch().await)
3168                .map_err(|err| {
3169                    (
3170                        HostCallErrorCode::Internal,
3171                        format!("Serialize branch: {err}"),
3172                    )
3173                }),
3174            "get_file" | "getfile" => {
3175                let state = self.session.get_state().await;
3176                let file = state
3177                    .get("sessionFile")
3178                    .or_else(|| state.get("session_file"))
3179                    .cloned()
3180                    .unwrap_or(Value::Null);
3181                Ok(file)
3182            }
3183            "get_name" | "getname" => {
3184                let state = self.session.get_state().await;
3185                let name = state
3186                    .get("sessionName")
3187                    .or_else(|| state.get("session_name"))
3188                    .cloned()
3189                    .unwrap_or(Value::Null);
3190                Ok(name)
3191            }
3192            "set_name" | "setname" => {
3193                let name = payload
3194                    .get("name")
3195                    .and_then(Value::as_str)
3196                    .unwrap_or_default()
3197                    .to_string();
3198                self.session
3199                    .set_name(name)
3200                    .await
3201                    .map(|()| Value::Null)
3202                    .map_err(|err| (HostCallErrorCode::Io, err.to_string()))
3203            }
3204            "append_entry" | "appendentry" => {
3205                let custom_type = payload
3206                    .get("customType")
3207                    .and_then(Value::as_str)
3208                    .or_else(|| payload.get("custom_type").and_then(Value::as_str))
3209                    .unwrap_or_default()
3210                    .to_string();
3211                let data = payload.get("data").cloned();
3212                self.session
3213                    .append_custom_entry(custom_type, data)
3214                    .await
3215                    .map(|()| Value::Null)
3216                    .map_err(|err| (HostCallErrorCode::Io, err.to_string()))
3217            }
3218            "append_message" | "appendmessage" => {
3219                let message_value = payload
3220                    .get("message")
3221                    .cloned()
3222                    .unwrap_or_else(|| payload.clone());
3223                match serde_json::from_value(message_value) {
3224                    Ok(message) => self
3225                        .session
3226                        .append_message(message)
3227                        .await
3228                        .map(|()| Value::Null)
3229                        .map_err(|err| (HostCallErrorCode::Io, err.to_string())),
3230                    Err(err) => Err((
3231                        HostCallErrorCode::InvalidRequest,
3232                        format!("Parse message: {err}"),
3233                    )),
3234                }
3235            }
3236            "set_model" | "setmodel" => {
3237                let provider = payload
3238                    .get("provider")
3239                    .and_then(Value::as_str)
3240                    .unwrap_or_default()
3241                    .to_string();
3242                let model_id = payload
3243                    .get("modelId")
3244                    .and_then(Value::as_str)
3245                    .or_else(|| payload.get("model_id").and_then(Value::as_str))
3246                    .unwrap_or_default()
3247                    .to_string();
3248                if provider.is_empty() || model_id.is_empty() {
3249                    Err((
3250                        HostCallErrorCode::InvalidRequest,
3251                        "set_model requires 'provider' and 'modelId' fields".to_string(),
3252                    ))
3253                } else {
3254                    self.session
3255                        .set_model(provider, model_id)
3256                        .await
3257                        .map(|()| Value::Bool(true))
3258                        .map_err(|err| (HostCallErrorCode::Io, err.to_string()))
3259                }
3260            }
3261            "get_model" | "getmodel" => {
3262                let (provider, model_id) = self.session.get_model().await;
3263                Ok(serde_json::json!({
3264                    "provider": provider,
3265                    "modelId": model_id,
3266                }))
3267            }
3268            "set_thinking_level" | "setthinkinglevel" => {
3269                let level = payload
3270                    .get("level")
3271                    .and_then(Value::as_str)
3272                    .or_else(|| payload.get("thinkingLevel").and_then(Value::as_str))
3273                    .or_else(|| payload.get("thinking_level").and_then(Value::as_str))
3274                    .unwrap_or_default()
3275                    .to_string();
3276                if level.is_empty() {
3277                    Err((
3278                        HostCallErrorCode::InvalidRequest,
3279                        "set_thinking_level requires 'level' field".to_string(),
3280                    ))
3281                } else {
3282                    self.session
3283                        .set_thinking_level(level)
3284                        .await
3285                        .map(|()| Value::Null)
3286                        .map_err(|err| (HostCallErrorCode::Io, err.to_string()))
3287                }
3288            }
3289            "get_thinking_level" | "getthinkinglevel" => {
3290                let level = self.session.get_thinking_level().await;
3291                Ok(level.map_or(Value::Null, Value::String))
3292            }
3293            "set_label" | "setlabel" => {
3294                let target_id = payload
3295                    .get("targetId")
3296                    .and_then(Value::as_str)
3297                    .or_else(|| payload.get("target_id").and_then(Value::as_str))
3298                    .unwrap_or_default()
3299                    .to_string();
3300                let label = payload
3301                    .get("label")
3302                    .and_then(Value::as_str)
3303                    .map(String::from);
3304                if target_id.is_empty() {
3305                    Err((
3306                        HostCallErrorCode::InvalidRequest,
3307                        "set_label requires 'targetId' field".to_string(),
3308                    ))
3309                } else {
3310                    self.session
3311                        .set_label(target_id, label)
3312                        .await
3313                        .map(|()| Value::Null)
3314                        .map_err(|err| (HostCallErrorCode::Io, err.to_string()))
3315                }
3316            }
3317            _ => Err((
3318                HostCallErrorCode::InvalidRequest,
3319                format!("Unknown session op: {op}"),
3320            )),
3321        };
3322
3323        match result {
3324            Ok(value) => HostcallOutcome::Success(value),
3325            Err((code, message)) => HostcallOutcome::Error {
3326                code: hostcall_code_to_str(code).to_string(),
3327                message,
3328            },
3329        }
3330    }
3331
3332    #[allow(clippy::future_not_send)]
3333    async fn dispatch_ui(
3334        &self,
3335        call_id: &str,
3336        op: &str,
3337        payload: Value,
3338        extension_id: Option<&str>,
3339    ) -> HostcallOutcome {
3340        let op = op.trim();
3341        if op.is_empty() {
3342            return HostcallOutcome::Error {
3343                code: "invalid_request".to_string(),
3344                message: "host_call ui requires non-empty op".to_string(),
3345            };
3346        }
3347
3348        let request = ExtensionUiRequest {
3349            id: call_id.to_string(),
3350            method: op.to_string(),
3351            payload,
3352            timeout_ms: None,
3353            extension_id: extension_id.map(ToString::to_string),
3354        };
3355
3356        match self.ui_handler.request_ui(request).await {
3357            Ok(Some(response)) => HostcallOutcome::Success(ui_response_value_for_op(op, &response)),
3358            Ok(None) => HostcallOutcome::Success(Value::Null),
3359            Err(err) => HostcallOutcome::Error {
3360                code: classify_ui_hostcall_error(&err).to_string(),
3361                message: err.to_string(),
3362            },
3363        }
3364    }
3365
3366    #[allow(clippy::future_not_send)]
3367    async fn dispatch_events(
3368        &self,
3369        call_id: &str,
3370        extension_id: Option<&str>,
3371        op: &str,
3372        payload: Value,
3373    ) -> HostcallOutcome {
3374        self.dispatch_events_ref(call_id, extension_id, op, &payload)
3375            .await
3376    }
3377
3378    #[allow(clippy::future_not_send)]
3379    async fn dispatch_events_ref(
3380        &self,
3381        _call_id: &str,
3382        extension_id: Option<&str>,
3383        op: &str,
3384        payload: &Value,
3385    ) -> HostcallOutcome {
3386        match op.trim() {
3387            "list" => match self.list_extension_events(extension_id).await {
3388                Ok(events) => HostcallOutcome::Success(serde_json::json!({ "events": events })),
3389                Err(err) => HostcallOutcome::Error {
3390                    code: "io".to_string(),
3391                    message: err.to_string(),
3392                },
3393            },
3394            "emit" => {
3395                let event_name = payload
3396                    .get("event")
3397                    .or_else(|| payload.get("name"))
3398                    .and_then(Value::as_str)
3399                    .map(str::trim)
3400                    .filter(|name| !name.is_empty());
3401
3402                let Some(event_name) = event_name else {
3403                    return HostcallOutcome::Error {
3404                        code: "invalid_request".to_string(),
3405                        message: "events.emit requires non-empty `event`".to_string(),
3406                    };
3407                };
3408
3409                let event_payload = payload.get("data").cloned().unwrap_or(Value::Null);
3410                let timeout_ms = payload
3411                    .get("timeout_ms")
3412                    .and_then(Value::as_u64)
3413                    .or_else(|| payload.get("timeoutMs").and_then(Value::as_u64))
3414                    .or_else(|| payload.get("timeout").and_then(Value::as_u64))
3415                    .filter(|ms| *ms > 0)
3416                    .unwrap_or(EXTENSION_EVENT_TIMEOUT_MS);
3417
3418                let ctx_payload = match payload.get("ctx") {
3419                    Some(ctx) => ctx.clone(),
3420                    None => self.build_default_event_ctx(extension_id).await,
3421                };
3422
3423                match Box::pin(self.dispatch_extension_event(
3424                    event_name,
3425                    event_payload,
3426                    ctx_payload,
3427                    timeout_ms,
3428                ))
3429                .await
3430                {
3431                    Ok(result) => {
3432                        let handler_count = self
3433                            .count_event_handlers(event_name)
3434                            .await
3435                            .unwrap_or_default();
3436
3437                        HostcallOutcome::Success(serde_json::json!({
3438                            "dispatched": true,
3439                            "event": event_name,
3440                            "handler_count": handler_count,
3441                            "result": result,
3442                        }))
3443                    }
3444                    Err(err) => HostcallOutcome::Error {
3445                        code: "io".to_string(),
3446                        message: err.to_string(),
3447                    },
3448                }
3449            }
3450            other => HostcallOutcome::Error {
3451                code: "invalid_request".to_string(),
3452                message: format!("Unsupported events op: {other}"),
3453            },
3454        }
3455    }
3456
3457    #[allow(clippy::future_not_send)]
3458    async fn list_extension_events(&self, extension_id: Option<&str>) -> Result<Vec<String>> {
3459        #[derive(serde::Deserialize)]
3460        struct Snapshot {
3461            id: String,
3462            #[serde(default)]
3463            event_hooks: Vec<String>,
3464        }
3465
3466        let json = self
3467            .js_runtime()
3468            .with_ctx(|ctx| {
3469                let global = ctx.globals();
3470                let snapshot_fn: rquickjs::Function<'_> = global.get("__pi_snapshot_extensions")?;
3471                let value: rquickjs::Value<'_> = snapshot_fn.call(())?;
3472                js_to_json(&value)
3473            })
3474            .await?;
3475
3476        let snapshots: Vec<Snapshot> = serde_json::from_value(json)
3477            .map_err(|err| crate::error::Error::extension(err.to_string()))?;
3478
3479        let mut events = BTreeSet::new();
3480        match extension_id {
3481            Some(needle) => {
3482                for snapshot in snapshots {
3483                    if snapshot.id == needle {
3484                        for event in snapshot.event_hooks {
3485                            let event = event.trim();
3486                            if !event.is_empty() {
3487                                events.insert(event.to_string());
3488                            }
3489                        }
3490                        break;
3491                    }
3492                }
3493            }
3494            None => {
3495                for snapshot in snapshots {
3496                    for event in snapshot.event_hooks {
3497                        let event = event.trim();
3498                        if !event.is_empty() {
3499                            events.insert(event.to_string());
3500                        }
3501                    }
3502                }
3503            }
3504        }
3505
3506        Ok(events.into_iter().collect())
3507    }
3508
3509    #[allow(clippy::future_not_send)]
3510    async fn count_event_handlers(&self, event_name: &str) -> Result<Option<usize>> {
3511        let literal = serde_json::to_string(event_name)
3512            .map_err(|err| crate::error::Error::extension(err.to_string()))?;
3513
3514        self.js_runtime()
3515            .with_ctx(|ctx| {
3516                let code = format!(
3517                    "(function() {{ const handlers = (__pi_hook_index.get({literal}) || []); return handlers.length; }})()"
3518                );
3519                ctx.eval::<usize, _>(code)
3520                    .map(Some)
3521                    .or(Ok(None))
3522            })
3523            .await
3524    }
3525
3526    #[allow(clippy::future_not_send)]
3527    async fn build_default_event_ctx(&self, _extension_id: Option<&str>) -> Value {
3528        let entries = self.session.get_entries().await;
3529        let branch = self.session.get_branch().await;
3530        let leaf_entry = branch.last().cloned().unwrap_or(Value::Null);
3531
3532        serde_json::json!({
3533            "hasUI": true,
3534            "cwd": self.cwd.display().to_string(),
3535            "sessionEntries": entries,
3536            "branch": branch,
3537            "leafEntry": leaf_entry,
3538            "modelRegistry": {},
3539        })
3540    }
3541
3542    #[allow(clippy::future_not_send)]
3543    async fn dispatch_extension_event(
3544        &self,
3545        event_name: &str,
3546        event_payload: Value,
3547        ctx_payload: Value,
3548        timeout_ms: u64,
3549    ) -> Result<Value> {
3550        #[derive(serde::Deserialize)]
3551        struct JsTaskError {
3552            #[serde(default)]
3553            code: Option<String>,
3554            message: String,
3555            #[serde(default)]
3556            stack: Option<String>,
3557        }
3558
3559        #[derive(serde::Deserialize)]
3560        struct JsTaskState {
3561            status: String,
3562            #[serde(default)]
3563            value: Option<Value>,
3564            #[serde(default)]
3565            error: Option<JsTaskError>,
3566        }
3567
3568        let task_id = format!("task-events-{call_id}", call_id = uuid::Uuid::new_v4());
3569
3570        self.js_runtime()
3571            .with_ctx(|ctx| {
3572                let global = ctx.globals();
3573                let dispatch_fn: rquickjs::Function<'_> =
3574                    global.get("__pi_dispatch_extension_event")?;
3575                let task_start: rquickjs::Function<'_> = global.get("__pi_task_start")?;
3576
3577                let event_js = json_to_js(&ctx, &event_payload)?;
3578                let ctx_js = json_to_js(&ctx, &ctx_payload)?;
3579                let promise: rquickjs::Value<'_> =
3580                    dispatch_fn.call((event_name.to_string(), event_js, ctx_js))?;
3581                let _task: String = task_start.call((task_id.clone(), promise))?;
3582                Ok(())
3583            })
3584            .await?;
3585
3586        let start = extension_wait_now();
3587        let timeout = Duration::from_millis(timeout_ms.max(1));
3588
3589        loop {
3590            let now = extension_wait_now();
3591            if std::time::Duration::from_nanos(now.duration_since(start)) > timeout {
3592                return Err(crate::error::Error::extension(format!(
3593                    "events.emit timed out after {}ms",
3594                    timeout.as_millis()
3595                )));
3596            }
3597
3598            let pending = self.js_runtime().drain_hostcall_requests();
3599            self.dispatch_batch_amac(pending).await;
3600
3601            let _ = self.js_runtime().tick().await?;
3602            let _ = self.js_runtime().drain_microtasks().await?;
3603
3604            let state_json = self
3605                .js_runtime()
3606                .with_ctx(|ctx| {
3607                    let global = ctx.globals();
3608                    let take_fn: rquickjs::Function<'_> = global.get("__pi_task_take")?;
3609                    let value: rquickjs::Value<'_> = take_fn.call((task_id.clone(),))?;
3610                    js_to_json(&value)
3611                })
3612                .await?;
3613
3614            if state_json.is_null() {
3615                return Err(crate::error::Error::extension(
3616                    "events.emit task state missing".to_string(),
3617                ));
3618            }
3619
3620            let state: JsTaskState = serde_json::from_value(state_json)
3621                .map_err(|err| crate::error::Error::extension(err.to_string()))?;
3622
3623            match state.status.as_str() {
3624                "pending" => {
3625                    if !self.js_runtime().has_pending() {
3626                        extension_wait_sleep(Duration::from_millis(1)).await;
3627                    }
3628                }
3629                "resolved" => return Ok(state.value.unwrap_or(Value::Null)),
3630                "rejected" => {
3631                    let err = state.error.unwrap_or_else(|| JsTaskError {
3632                        code: None,
3633                        message: "Unknown JS task error".to_string(),
3634                        stack: None,
3635                    });
3636                    let mut message = err.message;
3637                    if let Some(code) = err.code {
3638                        message = format!("{code}: {message}");
3639                    }
3640                    if let Some(stack) = err.stack {
3641                        if !stack.is_empty() {
3642                            message.push('\n');
3643                            message.push_str(&stack);
3644                        }
3645                    }
3646                    return Err(crate::error::Error::extension(message));
3647                }
3648                other => {
3649                    return Err(crate::error::Error::extension(format!(
3650                        "Unexpected JS task status: {other}"
3651                    )));
3652                }
3653            }
3654
3655            extension_wait_sleep(Duration::from_millis(0)).await;
3656        }
3657    }
3658}
3659
3660const fn hostcall_code_to_str(code: crate::connectors::HostCallErrorCode) -> &'static str {
3661    match code {
3662        crate::connectors::HostCallErrorCode::Timeout => "timeout",
3663        crate::connectors::HostCallErrorCode::Denied => "denied",
3664        crate::connectors::HostCallErrorCode::Io => "io",
3665        crate::connectors::HostCallErrorCode::InvalidRequest => "invalid_request",
3666        crate::connectors::HostCallErrorCode::Internal => "internal",
3667    }
3668}
3669
3670/// Trait for handling individual hostcall types.
3671#[async_trait]
3672pub trait HostcallHandler: Send + Sync {
3673    /// Process a hostcall request and return the outcome.
3674    async fn handle(&self, params: serde_json::Value) -> HostcallOutcome;
3675
3676    /// The capability name for policy checking (e.g., "read", "exec", "http").
3677    fn capability(&self) -> &'static str;
3678}
3679
3680/// Trait for handling UI hostcalls (pi.ui()).
3681#[async_trait]
3682pub trait ExtensionUiHandler: Send + Sync {
3683    async fn request_ui(&self, request: ExtensionUiRequest) -> Result<Option<ExtensionUiResponse>>;
3684}
3685
3686#[cfg(test)]
3687#[allow(clippy::arc_with_non_send_sync)]
3688mod tests {
3689    use super::*;
3690
3691    use crate::connectors::http::HttpConnectorConfig;
3692    use crate::error::Error;
3693    use crate::extensions::{
3694        ExtensionBody, ExtensionMessage, ExtensionOverride, ExtensionPolicyMode, HostCallPayload,
3695        PROTOCOL_VERSION, PolicyProfile,
3696    };
3697    use crate::scheduler::DeterministicClock;
3698    use crate::session::SessionMessage;
3699    use serde_json::Value;
3700    use std::collections::HashMap;
3701    use std::io::{Read, Write};
3702    use std::net::TcpListener;
3703    use std::path::Path;
3704    use std::sync::Mutex;
3705
3706    #[test]
3707    fn extension_wait_sleep_uses_current_timer_driver_epoch() {
3708        use asupersync::time::{TimerDriverHandle, VirtualClock};
3709        use asupersync::types::{Budget, RegionId, TaskId, Time};
3710        use std::sync::Arc;
3711
3712        let virtual_clock = Arc::new(VirtualClock::starting_at(Time::from_secs(42)));
3713        let timer_driver = TimerDriverHandle::with_virtual_clock(virtual_clock);
3714        let cx = Cx::new_with_drivers(
3715            RegionId::new_for_test(7, 0),
3716            TaskId::new_for_test(9, 0),
3717            Budget::INFINITE,
3718            None,
3719            None,
3720            None,
3721            Some(timer_driver.clone()),
3722            None,
3723        );
3724        let _current = Cx::set_current(Some(cx));
3725
3726        let now = extension_wait_now();
3727        assert_eq!(now, timer_driver.now());
3728        let sleeper = extension_wait_sleep(Duration::from_millis(5));
3729        assert_eq!(sleeper.remaining(now), Duration::from_millis(5));
3730    }
3731
3732    #[test]
3733    fn ui_confirm_cancel_defaults_to_false() {
3734        let response = ExtensionUiResponse {
3735            id: "req-1".to_string(),
3736            value: None,
3737            cancelled: true,
3738        };
3739        assert_eq!(
3740            ui_response_value_for_op("confirm", &response),
3741            Value::Bool(false)
3742        );
3743        assert_eq!(ui_response_value_for_op("select", &response), Value::Null);
3744    }
3745
3746    #[test]
3747    fn policy_snapshot_version_is_deterministic_for_equivalent_policies() {
3748        let mut policy_a = ExtensionPolicy::default();
3749        let mut override_a = ExtensionOverride::default();
3750        override_a.allow.push("exec".to_string());
3751        policy_a
3752            .per_extension
3753            .insert("ext.alpha".to_string(), override_a.clone());
3754        policy_a
3755            .per_extension
3756            .insert("ext.beta".to_string(), override_a);
3757
3758        let mut policy_b = ExtensionPolicy::default();
3759        let mut override_b = ExtensionOverride::default();
3760        override_b.allow.push("exec".to_string());
3761        // Insert in reverse order to verify canonical hashing is order-insensitive.
3762        policy_b
3763            .per_extension
3764            .insert("ext.beta".to_string(), override_b.clone());
3765        policy_b
3766            .per_extension
3767            .insert("ext.alpha".to_string(), override_b);
3768
3769        assert_eq!(
3770            policy_snapshot_version(&policy_a),
3771            policy_snapshot_version(&policy_b)
3772        );
3773    }
3774
3775    #[test]
3776    fn policy_snapshot_version_changes_on_material_policy_delta() {
3777        let policy_base = ExtensionPolicy::from_profile(PolicyProfile::Standard);
3778        let mut policy_delta = policy_base.clone();
3779        policy_delta.deny_caps.push("http".to_string());
3780
3781        assert_ne!(
3782            policy_snapshot_version(&policy_base),
3783            policy_snapshot_version(&policy_delta)
3784        );
3785    }
3786
3787    #[test]
3788    fn policy_lookup_path_marks_known_vs_fallback_capabilities() {
3789        assert_eq!(policy_lookup_path("read"), "policy_snapshot_table");
3790        assert_eq!(policy_lookup_path("READ"), "policy_snapshot_table");
3791        assert_eq!(
3792            policy_lookup_path("non_standard_custom_capability"),
3793            "policy_snapshot_fallback"
3794        );
3795    }
3796
3797    #[test]
3798    fn policy_snapshot_lookup_swaps_decision_across_profile_change() {
3799        let safe_policy = ExtensionPolicy::from_profile(PolicyProfile::Safe);
3800        let permissive_policy = ExtensionPolicy::from_profile(PolicyProfile::Permissive);
3801
3802        let safe_snapshot = PolicySnapshot::compile(&safe_policy);
3803        let permissive_snapshot = PolicySnapshot::compile(&permissive_policy);
3804
3805        let safe_first = safe_snapshot.lookup("exec", Some("ext.swap"));
3806        let safe_second = safe_snapshot.lookup("EXEC", Some("ext.swap"));
3807        assert_eq!(safe_first.decision, PolicyDecision::Deny);
3808        assert_eq!(safe_first.decision, safe_second.decision);
3809
3810        let permissive_first = permissive_snapshot.lookup("exec", Some("ext.swap"));
3811        let permissive_second = permissive_snapshot.lookup("EXEC", Some("ext.swap"));
3812        assert_eq!(permissive_first.decision, PolicyDecision::Allow);
3813        assert_eq!(permissive_first.decision, permissive_second.decision);
3814    }
3815
3816    struct NullSession;
3817
3818    #[async_trait]
3819    impl ExtensionSession for NullSession {
3820        async fn get_state(&self) -> Value {
3821            Value::Null
3822        }
3823
3824        async fn get_messages(&self) -> Vec<SessionMessage> {
3825            Vec::new()
3826        }
3827
3828        async fn get_entries(&self) -> Vec<Value> {
3829            Vec::new()
3830        }
3831
3832        async fn get_branch(&self) -> Vec<Value> {
3833            Vec::new()
3834        }
3835
3836        async fn set_name(&self, _name: String) -> Result<()> {
3837            Ok(())
3838        }
3839
3840        async fn append_message(&self, _message: SessionMessage) -> Result<()> {
3841            Ok(())
3842        }
3843
3844        async fn append_custom_entry(
3845            &self,
3846            _custom_type: String,
3847            _data: Option<Value>,
3848        ) -> Result<()> {
3849            Ok(())
3850        }
3851
3852        async fn set_model(&self, _provider: String, _model_id: String) -> Result<()> {
3853            Ok(())
3854        }
3855
3856        async fn get_model(&self) -> (Option<String>, Option<String>) {
3857            (None, None)
3858        }
3859
3860        async fn set_thinking_level(&self, _level: String) -> Result<()> {
3861            Ok(())
3862        }
3863
3864        async fn get_thinking_level(&self) -> Option<String> {
3865            None
3866        }
3867
3868        async fn set_label(&self, _target_id: String, _label: Option<String>) -> Result<()> {
3869            Ok(())
3870        }
3871    }
3872
3873    struct NullUiHandler;
3874
3875    #[async_trait]
3876    impl ExtensionUiHandler for NullUiHandler {
3877        async fn request_ui(
3878            &self,
3879            _request: ExtensionUiRequest,
3880        ) -> Result<Option<ExtensionUiResponse>> {
3881            Ok(None)
3882        }
3883    }
3884
3885    struct TestUiHandler {
3886        captured: Arc<Mutex<Vec<ExtensionUiRequest>>>,
3887        response_value: Value,
3888    }
3889
3890    #[async_trait]
3891    impl ExtensionUiHandler for TestUiHandler {
3892        async fn request_ui(
3893            &self,
3894            request: ExtensionUiRequest,
3895        ) -> Result<Option<ExtensionUiResponse>> {
3896            self.captured
3897                .lock()
3898                .unwrap_or_else(std::sync::PoisonError::into_inner)
3899                .push(request.clone());
3900            Ok(Some(ExtensionUiResponse {
3901                id: request.id,
3902                value: Some(self.response_value.clone()),
3903                cancelled: false,
3904            }))
3905        }
3906    }
3907
3908    type CustomEntry = (String, Option<Value>);
3909    type CustomEntries = Arc<Mutex<Vec<CustomEntry>>>;
3910
3911    type LabelEntry = (String, Option<String>);
3912
3913    struct TestSession {
3914        state: Arc<Mutex<Value>>,
3915        messages: Arc<Mutex<Vec<SessionMessage>>>,
3916        entries: Arc<Mutex<Vec<Value>>>,
3917        branch: Arc<Mutex<Vec<Value>>>,
3918        name: Arc<Mutex<Option<String>>>,
3919        custom_entries: CustomEntries,
3920        labels: Arc<Mutex<Vec<LabelEntry>>>,
3921    }
3922
3923    #[async_trait]
3924    impl ExtensionSession for TestSession {
3925        async fn get_state(&self) -> Value {
3926            self.state
3927                .lock()
3928                .unwrap_or_else(std::sync::PoisonError::into_inner)
3929                .clone()
3930        }
3931
3932        async fn get_messages(&self) -> Vec<SessionMessage> {
3933            self.messages
3934                .lock()
3935                .unwrap_or_else(std::sync::PoisonError::into_inner)
3936                .clone()
3937        }
3938
3939        async fn get_entries(&self) -> Vec<Value> {
3940            self.entries
3941                .lock()
3942                .unwrap_or_else(std::sync::PoisonError::into_inner)
3943                .clone()
3944        }
3945
3946        async fn get_branch(&self) -> Vec<Value> {
3947            self.branch
3948                .lock()
3949                .unwrap_or_else(std::sync::PoisonError::into_inner)
3950                .clone()
3951        }
3952
3953        async fn set_name(&self, name: String) -> Result<()> {
3954            {
3955                let mut guard = self
3956                    .name
3957                    .lock()
3958                    .unwrap_or_else(std::sync::PoisonError::into_inner);
3959                *guard = Some(name.clone());
3960            }
3961            let mut state = self
3962                .state
3963                .lock()
3964                .unwrap_or_else(std::sync::PoisonError::into_inner);
3965            if let Value::Object(ref mut map) = *state {
3966                map.insert("sessionName".to_string(), Value::String(name));
3967            }
3968            drop(state);
3969            Ok(())
3970        }
3971
3972        async fn append_message(&self, message: SessionMessage) -> Result<()> {
3973            self.messages
3974                .lock()
3975                .unwrap_or_else(std::sync::PoisonError::into_inner)
3976                .push(message);
3977            Ok(())
3978        }
3979
3980        async fn append_custom_entry(
3981            &self,
3982            custom_type: String,
3983            data: Option<Value>,
3984        ) -> Result<()> {
3985            self.custom_entries
3986                .lock()
3987                .unwrap_or_else(std::sync::PoisonError::into_inner)
3988                .push((custom_type, data));
3989            Ok(())
3990        }
3991
3992        async fn set_model(&self, provider: String, model_id: String) -> Result<()> {
3993            let mut state = self
3994                .state
3995                .lock()
3996                .unwrap_or_else(std::sync::PoisonError::into_inner);
3997            if let Value::Object(ref mut map) = *state {
3998                map.insert("provider".to_string(), Value::String(provider));
3999                map.insert("modelId".to_string(), Value::String(model_id));
4000            }
4001            drop(state);
4002            Ok(())
4003        }
4004
4005        async fn get_model(&self) -> (Option<String>, Option<String>) {
4006            let state = self
4007                .state
4008                .lock()
4009                .unwrap_or_else(std::sync::PoisonError::into_inner);
4010            let provider = state
4011                .get("provider")
4012                .and_then(Value::as_str)
4013                .map(String::from);
4014            let model_id = state
4015                .get("modelId")
4016                .and_then(Value::as_str)
4017                .map(String::from);
4018            drop(state);
4019            (provider, model_id)
4020        }
4021
4022        async fn set_thinking_level(&self, level: String) -> Result<()> {
4023            let mut state = self
4024                .state
4025                .lock()
4026                .unwrap_or_else(std::sync::PoisonError::into_inner);
4027            if let Value::Object(ref mut map) = *state {
4028                map.insert("thinkingLevel".to_string(), Value::String(level));
4029            }
4030            drop(state);
4031            Ok(())
4032        }
4033
4034        async fn get_thinking_level(&self) -> Option<String> {
4035            let state = self
4036                .state
4037                .lock()
4038                .unwrap_or_else(std::sync::PoisonError::into_inner);
4039            let level = state
4040                .get("thinkingLevel")
4041                .and_then(Value::as_str)
4042                .map(String::from);
4043            drop(state);
4044            level
4045        }
4046
4047        async fn set_label(&self, target_id: String, label: Option<String>) -> Result<()> {
4048            self.labels
4049                .lock()
4050                .unwrap_or_else(std::sync::PoisonError::into_inner)
4051                .push((target_id, label));
4052            Ok(())
4053        }
4054    }
4055
4056    fn build_dispatcher(
4057        runtime: Rc<PiJsRuntime<DeterministicClock>>,
4058    ) -> ExtensionDispatcher<DeterministicClock> {
4059        build_dispatcher_with_policy(
4060            runtime,
4061            ExtensionPolicy::from_profile(PolicyProfile::Permissive),
4062        )
4063    }
4064
4065    fn build_dispatcher_with_policy(
4066        runtime: Rc<PiJsRuntime<DeterministicClock>>,
4067        policy: ExtensionPolicy,
4068    ) -> ExtensionDispatcher<DeterministicClock> {
4069        ExtensionDispatcher::new_with_policy(
4070            runtime,
4071            Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
4072            Arc::new(HttpConnector::with_defaults()),
4073            Arc::new(NullSession),
4074            Arc::new(NullUiHandler),
4075            PathBuf::from("."),
4076            policy,
4077        )
4078    }
4079
4080    fn build_dispatcher_with_policy_and_oracle(
4081        runtime: Rc<PiJsRuntime<DeterministicClock>>,
4082        policy: ExtensionPolicy,
4083        oracle_config: DualExecOracleConfig,
4084    ) -> ExtensionDispatcher<DeterministicClock> {
4085        ExtensionDispatcher::new_with_policy_and_oracle_config(
4086            runtime,
4087            Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
4088            Arc::new(HttpConnector::with_defaults()),
4089            Arc::new(NullSession),
4090            Arc::new(NullUiHandler),
4091            PathBuf::from("."),
4092            policy,
4093            oracle_config,
4094        )
4095    }
4096
4097    fn spawn_http_server(body: &'static str) -> std::net::SocketAddr {
4098        let listener = TcpListener::bind("127.0.0.1:0").expect("bind http server");
4099        let addr = listener.local_addr().expect("server addr");
4100        thread::spawn(move || {
4101            if let Ok((mut stream, _)) = listener.accept() {
4102                let mut buf = [0u8; 1024];
4103                let _ = stream.read(&mut buf);
4104                let response = format!(
4105                    "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: text/plain\r\n\r\n{}",
4106                    body.len(),
4107                    body
4108                );
4109                let _ = stream.write_all(response.as_bytes());
4110            }
4111        });
4112        addr
4113    }
4114
4115    #[test]
4116    fn dispatcher_constructs() {
4117        futures::executor::block_on(async {
4118            let runtime = Rc::new(
4119                PiJsRuntime::with_clock(DeterministicClock::new(0))
4120                    .await
4121                    .expect("runtime"),
4122            );
4123            let dispatcher = build_dispatcher(Rc::clone(&runtime));
4124            assert!(std::ptr::eq(
4125                dispatcher.runtime.as_js_runtime(),
4126                runtime.as_ref()
4127            ));
4128            assert_eq!(dispatcher.cwd, PathBuf::from("."));
4129        });
4130    }
4131
4132    #[test]
4133    fn dispatcher_drains_empty_queue() {
4134        futures::executor::block_on(async {
4135            let runtime = Rc::new(
4136                PiJsRuntime::with_clock(DeterministicClock::new(0))
4137                    .await
4138                    .expect("runtime"),
4139            );
4140            let dispatcher = build_dispatcher(Rc::clone(&runtime));
4141            let drained = dispatcher.drain_hostcall_requests();
4142            assert!(drained.is_empty());
4143        });
4144    }
4145
4146    #[test]
4147    fn dispatcher_drains_runtime_requests() {
4148        futures::executor::block_on(async {
4149            let runtime = Rc::new(
4150                PiJsRuntime::with_clock(DeterministicClock::new(0))
4151                    .await
4152                    .expect("runtime"),
4153            );
4154            runtime
4155                .eval(r#"pi.tool("read", { "path": "test.txt" });"#)
4156                .await
4157                .expect("eval");
4158
4159            let dispatcher = build_dispatcher(Rc::clone(&runtime));
4160            let drained = dispatcher.drain_hostcall_requests();
4161            assert_eq!(drained.len(), 1);
4162        });
4163    }
4164
4165    #[test]
4166    fn dispatcher_tool_hostcall_executes_and_resolves_promise() {
4167        futures::executor::block_on(async {
4168            let temp_dir = tempfile::tempdir().expect("tempdir");
4169            std::fs::write(temp_dir.path().join("test.txt"), "hello world").expect("write file");
4170
4171            let runtime = Rc::new(
4172                PiJsRuntime::with_clock(DeterministicClock::new(0))
4173                    .await
4174                    .expect("runtime"),
4175            );
4176            runtime
4177                .eval(
4178                    r#"
4179                    globalThis.result = null;
4180                    pi.tool("read", { path: "test.txt" }).then((r) => { globalThis.result = r; });
4181                "#,
4182                )
4183                .await
4184                .expect("eval");
4185
4186            let requests = runtime.drain_hostcall_requests();
4187            assert_eq!(requests.len(), 1);
4188
4189            let dispatcher = ExtensionDispatcher::new(
4190                Rc::clone(&runtime),
4191                Arc::new(ToolRegistry::new(&["read"], temp_dir.path(), None)),
4192                Arc::new(HttpConnector::with_defaults()),
4193                Arc::new(NullSession),
4194                Arc::new(NullUiHandler),
4195                temp_dir.path().to_path_buf(),
4196            );
4197
4198            for request in requests {
4199                dispatcher.dispatch_and_complete(request).await;
4200            }
4201
4202            let stats = runtime.tick().await.expect("tick");
4203            assert!(stats.ran_macrotask);
4204
4205            runtime
4206                .eval(
4207                    r#"
4208                    if (globalThis.result === null) throw new Error("Promise not resolved");
4209                    if (!JSON.stringify(globalThis.result).includes("hello world")) {
4210                        throw new Error("Wrong result: " + JSON.stringify(globalThis.result));
4211                    }
4212                "#,
4213                )
4214                .await
4215                .expect("verify result");
4216        });
4217    }
4218
4219    #[test]
4220    fn dispatcher_tool_hostcall_unknown_tool_rejects_promise() {
4221        futures::executor::block_on(async {
4222            let runtime = Rc::new(
4223                PiJsRuntime::with_clock(DeterministicClock::new(0))
4224                    .await
4225                    .expect("runtime"),
4226            );
4227            runtime
4228                .eval(
4229                    r#"
4230                    globalThis.err = null;
4231                    pi.tool("nope", {}).catch((e) => { globalThis.err = e.code; });
4232                "#,
4233                )
4234                .await
4235                .expect("eval");
4236
4237            let requests = runtime.drain_hostcall_requests();
4238            assert_eq!(requests.len(), 1);
4239
4240            let dispatcher = build_dispatcher(Rc::clone(&runtime));
4241            for request in requests {
4242                dispatcher.dispatch_and_complete(request).await;
4243            }
4244
4245            while runtime.has_pending() {
4246                runtime.tick().await.expect("tick");
4247                runtime.drain_microtasks().await.expect("microtasks");
4248            }
4249
4250            runtime
4251                .eval(
4252                    r#"
4253                    if (globalThis.err === null) throw new Error("Promise not rejected");
4254                    if (globalThis.err !== "invalid_request") {
4255                        throw new Error("Wrong error code: " + globalThis.err);
4256                    }
4257                "#,
4258                )
4259                .await
4260                .expect("verify error");
4261        });
4262    }
4263
4264    #[test]
4265    fn dispatcher_session_hostcall_resolves_state_and_set_name() {
4266        futures::executor::block_on(async {
4267            let runtime = Rc::new(
4268                PiJsRuntime::with_clock(DeterministicClock::new(0))
4269                    .await
4270                    .expect("runtime"),
4271            );
4272
4273            runtime
4274                .eval(
4275                    r#"
4276                    globalThis.state = null;
4277                    globalThis.file = null;
4278                    globalThis.nameValue = null;
4279                    globalThis.nameSet = false;
4280                    pi.session("get_state", {}).then((r) => { globalThis.state = r; });
4281                    pi.session("get_file", {}).then((r) => { globalThis.file = r; });
4282                    pi.session("get_name", {}).then((r) => { globalThis.nameValue = r; });
4283                    pi.session("set_name", { name: "hello" }).then(() => { globalThis.nameSet = true; });
4284                "#,
4285                )
4286                .await
4287                .expect("eval");
4288
4289            let requests = runtime.drain_hostcall_requests();
4290            assert_eq!(requests.len(), 4);
4291
4292            let name = Arc::new(Mutex::new(None));
4293            let state = Arc::new(Mutex::new(serde_json::json!({
4294                "sessionFile": "/tmp/session.jsonl",
4295                "sessionName": "demo",
4296            })));
4297            let session = Arc::new(TestSession {
4298                state: Arc::clone(&state),
4299                messages: Arc::new(Mutex::new(Vec::new())),
4300                entries: Arc::new(Mutex::new(Vec::new())),
4301                branch: Arc::new(Mutex::new(Vec::new())),
4302                name: Arc::clone(&name),
4303                custom_entries: Arc::new(Mutex::new(Vec::new())),
4304                labels: Arc::new(Mutex::new(Vec::new())),
4305            });
4306
4307            let dispatcher = ExtensionDispatcher::new(
4308                Rc::clone(&runtime),
4309                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
4310                Arc::new(HttpConnector::with_defaults()),
4311                session,
4312                Arc::new(NullUiHandler),
4313                PathBuf::from("."),
4314            );
4315
4316            for request in requests {
4317                dispatcher.dispatch_and_complete(request).await;
4318            }
4319
4320            while runtime.has_pending() {
4321                runtime.tick().await.expect("tick");
4322                runtime.drain_microtasks().await.expect("microtasks");
4323            }
4324
4325            let (state_value, file_value, name_value, name_set) = runtime
4326                .with_ctx(|ctx| {
4327                    let global = ctx.globals();
4328                    let state_js: rquickjs::Value<'_> = global.get("state")?;
4329                    let file_js: rquickjs::Value<'_> = global.get("file")?;
4330                    let name_js: rquickjs::Value<'_> = global.get("nameValue")?;
4331                    let name_set_js: rquickjs::Value<'_> = global.get("nameSet")?;
4332                    Ok((
4333                        crate::extensions_js::js_to_json(&state_js)?,
4334                        crate::extensions_js::js_to_json(&file_js)?,
4335                        crate::extensions_js::js_to_json(&name_js)?,
4336                        crate::extensions_js::js_to_json(&name_set_js)?,
4337                    ))
4338                })
4339                .await
4340                .expect("read globals");
4341
4342            let state_file = state_value
4343                .get("sessionFile")
4344                .and_then(Value::as_str)
4345                .unwrap_or_default();
4346            assert_eq!(state_file, "/tmp/session.jsonl");
4347            assert_eq!(file_value, Value::String("/tmp/session.jsonl".to_string()));
4348            assert_eq!(name_value, Value::String("demo".to_string()));
4349            assert_eq!(name_set, Value::Bool(true));
4350
4351            let name_value = name
4352                .lock()
4353                .unwrap_or_else(std::sync::PoisonError::into_inner)
4354                .clone();
4355            assert_eq!(name_value.as_deref(), Some("hello"));
4356        });
4357    }
4358
4359    #[test]
4360    fn dispatcher_session_hostcall_get_messages_entries_branch() {
4361        futures::executor::block_on(async {
4362            let runtime = Rc::new(
4363                PiJsRuntime::with_clock(DeterministicClock::new(0))
4364                    .await
4365                    .expect("runtime"),
4366            );
4367
4368            runtime
4369                .eval(
4370                    r#"
4371                    globalThis.messages = null;
4372                    globalThis.entries = null;
4373                    globalThis.branch = null;
4374                    pi.session("get_messages", {}).then((r) => { globalThis.messages = r; });
4375                    pi.session("get_entries", {}).then((r) => { globalThis.entries = r; });
4376                    pi.session("get_branch", {}).then((r) => { globalThis.branch = r; });
4377                "#,
4378                )
4379                .await
4380                .expect("eval");
4381
4382            let requests = runtime.drain_hostcall_requests();
4383            assert_eq!(requests.len(), 3);
4384
4385            let message = SessionMessage::Custom {
4386                custom_type: "note".to_string(),
4387                content: "hello".to_string(),
4388                display: true,
4389                details: None,
4390                timestamp: Some(0),
4391            };
4392            let entries = vec![serde_json::json!({ "id": "entry-1", "type": "custom" })];
4393            let branch = vec![serde_json::json!({ "id": "entry-2", "type": "branch" })];
4394
4395            let session = Arc::new(TestSession {
4396                state: Arc::new(Mutex::new(Value::Null)),
4397                messages: Arc::new(Mutex::new(vec![message.clone()])),
4398                entries: Arc::new(Mutex::new(entries.clone())),
4399                branch: Arc::new(Mutex::new(branch.clone())),
4400                name: Arc::new(Mutex::new(None)),
4401                custom_entries: Arc::new(Mutex::new(Vec::new())),
4402                labels: Arc::new(Mutex::new(Vec::new())),
4403            });
4404
4405            let dispatcher = ExtensionDispatcher::new(
4406                Rc::clone(&runtime),
4407                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
4408                Arc::new(HttpConnector::with_defaults()),
4409                session,
4410                Arc::new(NullUiHandler),
4411                PathBuf::from("."),
4412            );
4413
4414            for request in requests {
4415                dispatcher.dispatch_and_complete(request).await;
4416            }
4417
4418            while runtime.has_pending() {
4419                runtime.tick().await.expect("tick");
4420                runtime.drain_microtasks().await.expect("microtasks");
4421            }
4422
4423            let (messages_value, entries_value, branch_value) = runtime
4424                .with_ctx(|ctx| {
4425                    let global = ctx.globals();
4426                    let messages_js: rquickjs::Value<'_> = global.get("messages")?;
4427                    let entries_js: rquickjs::Value<'_> = global.get("entries")?;
4428                    let branch_js: rquickjs::Value<'_> = global.get("branch")?;
4429                    Ok((
4430                        crate::extensions_js::js_to_json(&messages_js)?,
4431                        crate::extensions_js::js_to_json(&entries_js)?,
4432                        crate::extensions_js::js_to_json(&branch_js)?,
4433                    ))
4434                })
4435                .await
4436                .expect("read globals");
4437
4438            let messages_array = messages_value.as_array().expect("messages array");
4439            assert_eq!(messages_array.len(), 1);
4440            assert_eq!(
4441                messages_array[0]
4442                    .get("role")
4443                    .and_then(Value::as_str)
4444                    .unwrap_or_default(),
4445                "custom"
4446            );
4447            assert_eq!(
4448                messages_array[0]
4449                    .get("customType")
4450                    .and_then(Value::as_str)
4451                    .unwrap_or_default(),
4452                "note"
4453            );
4454            assert_eq!(entries_value, Value::Array(entries));
4455            assert_eq!(branch_value, Value::Array(branch));
4456        });
4457    }
4458
4459    #[test]
4460    #[allow(clippy::too_many_lines)]
4461    fn dispatcher_session_hostcall_append_message_and_entry() {
4462        futures::executor::block_on(async {
4463            let runtime = Rc::new(
4464                PiJsRuntime::with_clock(DeterministicClock::new(0))
4465                    .await
4466                    .expect("runtime"),
4467            );
4468
4469            runtime
4470                .eval(
4471                    r#"
4472                    globalThis.messageAppended = false;
4473                    globalThis.entryAppended = false;
4474                    pi.session("append_message", {
4475                        message: { role: "custom", customType: "note", content: "hi", display: true }
4476                    }).then(() => { globalThis.messageAppended = true; });
4477                    pi.session("append_entry", {
4478                        customType: "meta",
4479                        data: { ok: true }
4480                    }).then(() => { globalThis.entryAppended = true; });
4481                "#,
4482                )
4483                .await
4484                .expect("eval");
4485
4486            let requests = runtime.drain_hostcall_requests();
4487            assert_eq!(requests.len(), 2);
4488
4489            let session = Arc::new(TestSession {
4490                state: Arc::new(Mutex::new(Value::Null)),
4491                messages: Arc::new(Mutex::new(Vec::new())),
4492                entries: Arc::new(Mutex::new(Vec::new())),
4493                branch: Arc::new(Mutex::new(Vec::new())),
4494                name: Arc::new(Mutex::new(None)),
4495                custom_entries: Arc::new(Mutex::new(Vec::new())),
4496                labels: Arc::new(Mutex::new(Vec::new())),
4497            });
4498
4499            let dispatcher = ExtensionDispatcher::new(
4500                Rc::clone(&runtime),
4501                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
4502                Arc::new(HttpConnector::with_defaults()),
4503                {
4504                    let session_handle: Arc<dyn ExtensionSession + Send + Sync> = session.clone();
4505                    session_handle
4506                },
4507                Arc::new(NullUiHandler),
4508                PathBuf::from("."),
4509            );
4510
4511            for request in requests {
4512                dispatcher.dispatch_and_complete(request).await;
4513            }
4514
4515            while runtime.has_pending() {
4516                runtime.tick().await.expect("tick");
4517                runtime.drain_microtasks().await.expect("microtasks");
4518            }
4519
4520            let (message_appended, entry_appended) = runtime
4521                .with_ctx(|ctx| {
4522                    let global = ctx.globals();
4523                    let message_js: rquickjs::Value<'_> = global.get("messageAppended")?;
4524                    let entry_js: rquickjs::Value<'_> = global.get("entryAppended")?;
4525                    Ok((
4526                        crate::extensions_js::js_to_json(&message_js)?,
4527                        crate::extensions_js::js_to_json(&entry_js)?,
4528                    ))
4529                })
4530                .await
4531                .expect("read globals");
4532
4533            assert_eq!(message_appended, Value::Bool(true));
4534            assert_eq!(entry_appended, Value::Bool(true));
4535
4536            {
4537                let messages = session
4538                    .messages
4539                    .lock()
4540                    .unwrap_or_else(std::sync::PoisonError::into_inner)
4541                    .clone();
4542                assert_eq!(messages.len(), 1);
4543                match &messages[0] {
4544                    SessionMessage::Custom {
4545                        custom_type,
4546                        content,
4547                        display,
4548                        ..
4549                    } => {
4550                        assert_eq!(custom_type, "note");
4551                        assert_eq!(content, "hi");
4552                        assert!(*display);
4553                    }
4554                    other => assert!(
4555                        matches!(other, SessionMessage::Custom { .. }),
4556                        "Unexpected message: {other:?}"
4557                    ),
4558                }
4559            }
4560
4561            {
4562                let expected = Some(serde_json::json!({ "ok": true }));
4563                let custom_entries = session
4564                    .custom_entries
4565                    .lock()
4566                    .unwrap_or_else(std::sync::PoisonError::into_inner)
4567                    .clone();
4568                assert_eq!(custom_entries.len(), 1);
4569                assert_eq!(custom_entries[0].0, "meta");
4570                assert_eq!(custom_entries[0].1, expected);
4571                drop(custom_entries);
4572            }
4573        });
4574    }
4575
4576    #[test]
4577    fn dispatcher_session_hostcall_unknown_op_rejects_promise() {
4578        futures::executor::block_on(async {
4579            let runtime = Rc::new(
4580                PiJsRuntime::with_clock(DeterministicClock::new(0))
4581                    .await
4582                    .expect("runtime"),
4583            );
4584
4585            runtime
4586                .eval(
4587                    r#"
4588                    globalThis.err = null;
4589                    pi.session("nope", {}).catch((e) => { globalThis.err = e.code; });
4590                "#,
4591                )
4592                .await
4593                .expect("eval");
4594
4595            let requests = runtime.drain_hostcall_requests();
4596            assert_eq!(requests.len(), 1);
4597
4598            let dispatcher = build_dispatcher(Rc::clone(&runtime));
4599            for request in requests {
4600                dispatcher.dispatch_and_complete(request).await;
4601            }
4602
4603            while runtime.has_pending() {
4604                runtime.tick().await.expect("tick");
4605                runtime.drain_microtasks().await.expect("microtasks");
4606            }
4607
4608            let err_value = runtime
4609                .with_ctx(|ctx| {
4610                    let global = ctx.globals();
4611                    let err_js: rquickjs::Value<'_> = global.get("err")?;
4612                    crate::extensions_js::js_to_json(&err_js)
4613                })
4614                .await
4615                .expect("read globals");
4616
4617            assert_eq!(err_value, Value::String("invalid_request".to_string()));
4618        });
4619    }
4620
4621    #[test]
4622    fn dispatcher_session_hostcall_append_message_invalid_rejects_promise() {
4623        futures::executor::block_on(async {
4624            let runtime = Rc::new(
4625                PiJsRuntime::with_clock(DeterministicClock::new(0))
4626                    .await
4627                    .expect("runtime"),
4628            );
4629
4630            runtime
4631                .eval(
4632                    r#"
4633                    globalThis.err = null;
4634                    pi.session("append_message", { message: { nope: 1 } })
4635                        .catch((e) => { globalThis.err = e.code; });
4636                "#,
4637                )
4638                .await
4639                .expect("eval");
4640
4641            let requests = runtime.drain_hostcall_requests();
4642            assert_eq!(requests.len(), 1);
4643
4644            let dispatcher = build_dispatcher(Rc::clone(&runtime));
4645            for request in requests {
4646                dispatcher.dispatch_and_complete(request).await;
4647            }
4648
4649            while runtime.has_pending() {
4650                runtime.tick().await.expect("tick");
4651                runtime.drain_microtasks().await.expect("microtasks");
4652            }
4653
4654            let err_value = runtime
4655                .with_ctx(|ctx| {
4656                    let global = ctx.globals();
4657                    let err_js: rquickjs::Value<'_> = global.get("err")?;
4658                    crate::extensions_js::js_to_json(&err_js)
4659                })
4660                .await
4661                .expect("read globals");
4662
4663            assert_eq!(err_value, Value::String("invalid_request".to_string()));
4664        });
4665    }
4666
4667    #[test]
4668    #[cfg(unix)]
4669    fn dispatcher_exec_hostcall_executes_and_resolves_promise() {
4670        futures::executor::block_on(async {
4671            let runtime = Rc::new(
4672                PiJsRuntime::with_clock(DeterministicClock::new(0))
4673                    .await
4674                    .expect("runtime"),
4675            );
4676
4677            runtime
4678                .eval(
4679                    r#"
4680                    globalThis.result = null;
4681                    pi.exec("sh", ["-c", "printf hello"], {})
4682                        .then((r) => { globalThis.result = r; });
4683                "#,
4684                )
4685                .await
4686                .expect("eval");
4687
4688            let requests = runtime.drain_hostcall_requests();
4689            assert_eq!(requests.len(), 1);
4690
4691            let dispatcher = build_dispatcher(Rc::clone(&runtime));
4692            for request in requests {
4693                dispatcher.dispatch_and_complete(request).await;
4694            }
4695
4696            runtime.tick().await.expect("tick");
4697
4698            runtime
4699                .eval(
4700                    r#"
4701                    if (globalThis.result === null) throw new Error("Promise not resolved");
4702                    if (globalThis.result.stdout !== "hello") {
4703                        throw new Error("Wrong stdout: " + JSON.stringify(globalThis.result));
4704                    }
4705                    if (globalThis.result.code !== 0) {
4706                        throw new Error("Wrong exit code: " + JSON.stringify(globalThis.result));
4707                    }
4708                    if (globalThis.result.killed !== false) {
4709                        throw new Error("Unexpected killed flag: " + JSON.stringify(globalThis.result));
4710                    }
4711                "#,
4712                )
4713                .await
4714                .expect("verify result");
4715        });
4716    }
4717
4718    #[test]
4719    #[cfg(unix)]
4720    fn dispatcher_exec_hostcall_command_not_found_rejects_promise() {
4721        futures::executor::block_on(async {
4722            let runtime = Rc::new(
4723                PiJsRuntime::with_clock(DeterministicClock::new(0))
4724                    .await
4725                    .expect("runtime"),
4726            );
4727
4728            runtime
4729                .eval(
4730                    r#"
4731                    globalThis.err = null;
4732                    pi.exec("definitely_not_a_real_command", [], {})
4733                        .catch((e) => { globalThis.err = e.code; });
4734                "#,
4735                )
4736                .await
4737                .expect("eval");
4738
4739            let requests = runtime.drain_hostcall_requests();
4740            assert_eq!(requests.len(), 1);
4741
4742            let dispatcher = build_dispatcher(Rc::clone(&runtime));
4743            for request in requests {
4744                dispatcher.dispatch_and_complete(request).await;
4745            }
4746
4747            runtime.tick().await.expect("tick");
4748
4749            runtime
4750                .eval(
4751                    r#"
4752                    if (globalThis.err === null) throw new Error("Promise not rejected");
4753                    if (globalThis.err !== "io") {
4754                        throw new Error("Wrong error code: " + globalThis.err);
4755                    }
4756                "#,
4757                )
4758                .await
4759                .expect("verify error");
4760        });
4761    }
4762
4763    #[test]
4764    #[cfg(unix)]
4765    fn dispatcher_exec_hostcall_streaming_callback_delivers_chunks_and_final_result() {
4766        futures::executor::block_on(async {
4767            let runtime = Rc::new(
4768                PiJsRuntime::with_clock(DeterministicClock::new(0))
4769                    .await
4770                    .expect("runtime"),
4771            );
4772
4773            runtime
4774                .eval(
4775                    r#"
4776                    globalThis.chunks = [];
4777                    globalThis.finalResult = null;
4778                    pi.exec("sh", ["-c", "printf 'out-1\n'; printf 'err-1\n' 1>&2; printf 'out-2\n'"], {
4779                        stream: true,
4780                        onChunk: (chunk, isFinal) => {
4781                            globalThis.chunks.push({ chunk, isFinal });
4782                        },
4783                    }).then((r) => { globalThis.finalResult = r; });
4784                "#,
4785                )
4786                .await
4787                .expect("eval");
4788
4789            let requests = runtime.drain_hostcall_requests();
4790            assert_eq!(requests.len(), 1);
4791
4792            let dispatcher = build_dispatcher(Rc::clone(&runtime));
4793            for request in requests {
4794                dispatcher.dispatch_and_complete(request).await;
4795            }
4796
4797            while runtime.has_pending() {
4798                runtime.tick().await.expect("tick");
4799                runtime.drain_microtasks().await.expect("microtasks");
4800            }
4801
4802            runtime
4803                .eval(
4804                    r#"
4805                    if (!Array.isArray(globalThis.chunks) || globalThis.chunks.length < 3) {
4806                        throw new Error("Expected stream chunks, got: " + JSON.stringify(globalThis.chunks));
4807                    }
4808                    const sawStdout = globalThis.chunks.some((entry) => entry.chunk && entry.chunk.stdout && entry.chunk.stdout.includes("out-1"));
4809                    if (!sawStdout) {
4810                        throw new Error("Missing stdout chunk: " + JSON.stringify(globalThis.chunks));
4811                    }
4812                    const sawStderr = globalThis.chunks.some((entry) => entry.chunk && entry.chunk.stderr && entry.chunk.stderr.includes("err-1"));
4813                    if (!sawStderr) {
4814                        throw new Error("Missing stderr chunk: " + JSON.stringify(globalThis.chunks));
4815                    }
4816                    const finalEntry = globalThis.chunks[globalThis.chunks.length - 1];
4817                    if (!finalEntry || finalEntry.isFinal !== true) {
4818                        throw new Error("Missing final chunk marker: " + JSON.stringify(globalThis.chunks));
4819                    }
4820                    if (globalThis.finalResult === null) {
4821                        throw new Error("Promise not resolved");
4822                    }
4823                    if (globalThis.finalResult.code !== 0) {
4824                        throw new Error("Wrong exit code: " + JSON.stringify(globalThis.finalResult));
4825                    }
4826                    if (globalThis.finalResult.killed !== false) {
4827                        throw new Error("Unexpected killed flag: " + JSON.stringify(globalThis.finalResult));
4828                    }
4829                "#,
4830                )
4831                .await
4832                .expect("verify stream callback result");
4833        });
4834    }
4835
4836    #[test]
4837    #[cfg(unix)]
4838    fn dispatcher_exec_hostcall_streaming_async_iterator_delivers_chunks_in_order() {
4839        futures::executor::block_on(async {
4840            let runtime = Rc::new(
4841                PiJsRuntime::with_clock(DeterministicClock::new(0))
4842                    .await
4843                    .expect("runtime"),
4844            );
4845
4846            runtime
4847                .eval(
4848                    r#"
4849                    globalThis.iterChunks = [];
4850                    globalThis.iterDone = false;
4851                    (async () => {
4852                        const stream = pi.exec("sh", ["-c", "printf 'a\n'; printf 'b\n'"], { stream: true });
4853                        for await (const chunk of stream) {
4854                            globalThis.iterChunks.push(chunk);
4855                        }
4856                        globalThis.iterDone = true;
4857                    })();
4858                "#,
4859                )
4860                .await
4861                .expect("eval");
4862
4863            let requests = runtime.drain_hostcall_requests();
4864            assert_eq!(requests.len(), 1);
4865
4866            let dispatcher = build_dispatcher(Rc::clone(&runtime));
4867            for request in requests {
4868                dispatcher.dispatch_and_complete(request).await;
4869            }
4870
4871            while runtime.has_pending() {
4872                runtime.tick().await.expect("tick");
4873                runtime.drain_microtasks().await.expect("microtasks");
4874            }
4875
4876            runtime
4877                .eval(
4878                    r#"
4879                    if (globalThis.iterDone !== true) {
4880                        throw new Error("Async iterator did not finish");
4881                    }
4882                    if (!Array.isArray(globalThis.iterChunks) || globalThis.iterChunks.length < 2) {
4883                        throw new Error("Missing stream chunks: " + JSON.stringify(globalThis.iterChunks));
4884                    }
4885                    const stdout = globalThis.iterChunks
4886                        .map((chunk) => (chunk && typeof chunk.stdout === "string" ? chunk.stdout : ""))
4887                        .join("");
4888                    if (stdout !== "a\nb\n") {
4889                        throw new Error("Unexpected streamed stdout aggregate: " + JSON.stringify(globalThis.iterChunks));
4890                    }
4891                    const finalChunk = globalThis.iterChunks[globalThis.iterChunks.length - 1];
4892                    if (!finalChunk || finalChunk.code !== 0 || finalChunk.killed !== false) {
4893                        throw new Error("Unexpected final chunk: " + JSON.stringify(finalChunk));
4894                    }
4895                "#,
4896                )
4897                .await
4898                .expect("verify async iterator result");
4899        });
4900    }
4901
4902    #[test]
4903    #[cfg(unix)]
4904    fn dispatcher_exec_hostcall_handles_invalid_utf8() {
4905        futures::executor::block_on(async {
4906            let runtime = Rc::new(
4907                PiJsRuntime::with_clock(DeterministicClock::new(0))
4908                    .await
4909                    .expect("runtime"),
4910            );
4911
4912            // Output 'a', then invalid 0xFF, then 'b'.
4913            // Expected: 'a' in one chunk (or part of chunk), then replacement char, then 'b'.
4914            // Note: printf '\xff' might vary by shell, but \377 should work.
4915            runtime
4916                .eval(
4917                    r#"
4918                    globalThis.output = "";
4919                    globalThis.outputDone = false;
4920                    (async () => {
4921                        const stream = pi.exec("sh", ["-c", "printf 'a\\377b'"], { stream: true });
4922                        for await (const chunk of stream) {
4923                            if (chunk.stdout) globalThis.output += chunk.stdout;
4924                        }
4925                        globalThis.outputDone = true;
4926                    })();
4927                "#,
4928                )
4929                .await
4930                .expect("eval");
4931
4932            let requests = runtime.drain_hostcall_requests();
4933            assert_eq!(requests.len(), 1);
4934
4935            let dispatcher = build_dispatcher(Rc::clone(&runtime));
4936            for request in requests {
4937                dispatcher.dispatch_and_complete(request).await;
4938            }
4939
4940            while runtime.has_pending() {
4941                runtime.tick().await.expect("tick");
4942                runtime.drain_microtasks().await.expect("microtasks");
4943            }
4944
4945            runtime
4946                .eval(
4947                    r#"
4948                    if (globalThis.outputDone !== true) {
4949                        throw new Error("Streaming output collection did not finish");
4950                    }
4951                    // \uFFFD is the replacement character
4952                    if (globalThis.output !== "a\uFFFDb") {
4953                        throw new Error("Expected 'a\\uFFFDb', got: " + globalThis.output + " (len " + globalThis.output.length + ")");
4954                    }
4955                "#,
4956                )
4957                .await
4958                .expect("verify invalid utf8 handling");
4959        });
4960    }
4961
4962    #[test]
4963    #[cfg(unix)]
4964    #[ignore = "flaky on CI: timing-sensitive 500ms exec timeout with futures::executor"]
4965    fn dispatcher_exec_hostcall_streaming_timeout_marks_final_chunk_killed() {
4966        futures::executor::block_on(async {
4967            let runtime = Rc::new(
4968                PiJsRuntime::with_clock(DeterministicClock::new(0))
4969                    .await
4970                    .expect("runtime"),
4971            );
4972
4973            runtime
4974                .eval(
4975                    r#"
4976                    globalThis.timeoutChunks = [];
4977                    globalThis.timeoutResult = null;
4978                    globalThis.timeoutError = null;
4979                    pi.exec("sh", ["-c", "printf 'start\n'; sleep 5; printf 'late\n'"], {
4980                        stream: true,
4981                        timeoutMs: 500,
4982                        onChunk: (chunk, isFinal) => {
4983                            globalThis.timeoutChunks.push({ chunk, isFinal });
4984                        },
4985                    })
4986                        .then((r) => { globalThis.timeoutResult = r; })
4987                        .catch((e) => { globalThis.timeoutError = e; });
4988                "#,
4989                )
4990                .await
4991                .expect("eval");
4992
4993            let requests = runtime.drain_hostcall_requests();
4994            assert_eq!(requests.len(), 1);
4995
4996            let dispatcher = build_dispatcher(Rc::clone(&runtime));
4997            for request in requests {
4998                dispatcher.dispatch_and_complete(request).await;
4999            }
5000
5001            while runtime.has_pending() {
5002                runtime.tick().await.expect("tick");
5003                runtime.drain_microtasks().await.expect("microtasks");
5004            }
5005
5006            runtime
5007                .eval(
5008                    r#"
5009                    if (globalThis.timeoutError !== null) {
5010                        throw new Error("Unexpected timeout error: " + JSON.stringify(globalThis.timeoutError));
5011                    }
5012                    if (globalThis.timeoutResult === null) {
5013                        throw new Error("Timeout stream promise not resolved");
5014                    }
5015                    if (globalThis.timeoutResult.killed !== true) {
5016                        throw new Error("Expected killed=true for timeout stream: " + JSON.stringify(globalThis.timeoutResult));
5017                    }
5018                    const finalEntry = globalThis.timeoutChunks[globalThis.timeoutChunks.length - 1];
5019                    if (!finalEntry || finalEntry.isFinal !== true) {
5020                        throw new Error("Missing final timeout chunk marker: " + JSON.stringify(globalThis.timeoutChunks));
5021                    }
5022                    const sawLateOutput = globalThis.timeoutChunks.some((entry) =>
5023                        entry.chunk && entry.chunk.stdout && entry.chunk.stdout.includes("late")
5024                    );
5025                    if (sawLateOutput) {
5026                        throw new Error("Process output after timeout kill: " + JSON.stringify(globalThis.timeoutChunks));
5027                    }
5028                "#,
5029                )
5030                .await
5031                .expect("verify timeout stream result");
5032        });
5033    }
5034
5035    #[test]
5036    fn dispatcher_http_hostcall_executes_and_resolves_promise() {
5037        futures::executor::block_on(async {
5038            let addr = spawn_http_server("hello");
5039            let url = format!("http://{addr}/test");
5040
5041            let runtime = Rc::new(
5042                PiJsRuntime::with_clock(DeterministicClock::new(0))
5043                    .await
5044                    .expect("runtime"),
5045            );
5046
5047            let script = format!(
5048                r#"
5049                globalThis.result = null;
5050                pi.http({{ url: "{url}", method: "GET" }})
5051                    .then((r) => {{ globalThis.result = r; }});
5052            "#
5053            );
5054            runtime.eval(&script).await.expect("eval");
5055
5056            let requests = runtime.drain_hostcall_requests();
5057            assert_eq!(requests.len(), 1);
5058
5059            let http_connector = HttpConnector::new(HttpConnectorConfig {
5060                require_tls: false,
5061                ..Default::default()
5062            });
5063            let dispatcher = ExtensionDispatcher::new(
5064                Rc::clone(&runtime),
5065                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
5066                Arc::new(http_connector),
5067                Arc::new(NullSession),
5068                Arc::new(NullUiHandler),
5069                PathBuf::from("."),
5070            );
5071
5072            for request in requests {
5073                dispatcher.dispatch_and_complete(request).await;
5074            }
5075
5076            runtime.tick().await.expect("tick");
5077
5078            runtime
5079                .eval(
5080                    r#"
5081                    if (globalThis.result === null) throw new Error("Promise not resolved");
5082                    if (globalThis.result.status !== 200) {
5083                        throw new Error("Wrong status: " + globalThis.result.status);
5084                    }
5085                    if (globalThis.result.body !== "hello") {
5086                        throw new Error("Wrong body: " + globalThis.result.body);
5087                    }
5088                "#,
5089                )
5090                .await
5091                .expect("verify result");
5092        });
5093    }
5094
5095    #[test]
5096    fn dispatcher_http_hostcall_invalid_method_rejects_promise() {
5097        futures::executor::block_on(async {
5098            let runtime = Rc::new(
5099                PiJsRuntime::with_clock(DeterministicClock::new(0))
5100                    .await
5101                    .expect("runtime"),
5102            );
5103
5104            runtime
5105                .eval(
5106                    r#"
5107                    globalThis.err = null;
5108                    pi.http({ url: "https://example.com", method: "PUT" })
5109                        .catch((e) => { globalThis.err = e.code; });
5110                "#,
5111                )
5112                .await
5113                .expect("eval");
5114
5115            let requests = runtime.drain_hostcall_requests();
5116            assert_eq!(requests.len(), 1);
5117
5118            let http_connector = HttpConnector::new(HttpConnectorConfig {
5119                require_tls: false,
5120                ..Default::default()
5121            });
5122            let dispatcher = ExtensionDispatcher::new(
5123                Rc::clone(&runtime),
5124                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
5125                Arc::new(http_connector),
5126                Arc::new(NullSession),
5127                Arc::new(NullUiHandler),
5128                PathBuf::from("."),
5129            );
5130
5131            for request in requests {
5132                dispatcher.dispatch_and_complete(request).await;
5133            }
5134
5135            runtime.tick().await.expect("tick");
5136
5137            runtime
5138                .eval(
5139                    r#"
5140                    if (globalThis.err === null) throw new Error("Promise not rejected");
5141                    if (globalThis.err !== "invalid_request") {
5142                        throw new Error("Wrong error code: " + globalThis.err);
5143                    }
5144                "#,
5145                )
5146                .await
5147                .expect("verify error");
5148        });
5149    }
5150
5151    #[test]
5152    fn dispatcher_ui_hostcall_executes_and_resolves_promise() {
5153        futures::executor::block_on(async {
5154            let runtime = Rc::new(
5155                PiJsRuntime::with_clock(DeterministicClock::new(0))
5156                    .await
5157                    .expect("runtime"),
5158            );
5159
5160            runtime
5161                .eval(
5162                    r#"
5163                    globalThis.uiResult = null;
5164                    pi.ui("confirm", { title: "Confirm?" }).then((r) => { globalThis.uiResult = r; });
5165                "#,
5166                )
5167                .await
5168                .expect("eval");
5169
5170            let requests = runtime.drain_hostcall_requests();
5171            assert_eq!(requests.len(), 1);
5172
5173            let captured = Arc::new(Mutex::new(Vec::new()));
5174            let dispatcher = ExtensionDispatcher::new(
5175                Rc::clone(&runtime),
5176                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
5177                Arc::new(HttpConnector::with_defaults()),
5178                Arc::new(NullSession),
5179                Arc::new(TestUiHandler {
5180                    captured: Arc::clone(&captured),
5181                    response_value: serde_json::json!({ "ok": true }),
5182                }),
5183                PathBuf::from("."),
5184            );
5185
5186            for request in requests {
5187                dispatcher.dispatch_and_complete(request).await;
5188            }
5189
5190            runtime.tick().await.expect("tick");
5191
5192            runtime
5193                .eval(
5194                    r#"
5195                    if (!globalThis.uiResult || globalThis.uiResult.ok !== true) {
5196                        throw new Error("Wrong UI result: " + JSON.stringify(globalThis.uiResult));
5197                    }
5198                "#,
5199                )
5200                .await
5201                .expect("verify result");
5202
5203            let seen = captured
5204                .lock()
5205                .unwrap_or_else(std::sync::PoisonError::into_inner)
5206                .clone();
5207            assert_eq!(seen.len(), 1);
5208            assert_eq!(seen[0].method, "confirm");
5209        });
5210    }
5211
5212    #[test]
5213    fn dispatcher_extension_ui_set_status_includes_text_field() {
5214        futures::executor::block_on(async {
5215            let runtime = Rc::new(
5216                PiJsRuntime::with_clock(DeterministicClock::new(0))
5217                    .await
5218                    .expect("runtime"),
5219            );
5220
5221            runtime
5222                .eval(
5223                    r#"
5224                    const ui = __pi_make_extension_ui(true);
5225                    ui.setStatus("key", "hello");
5226                "#,
5227                )
5228                .await
5229                .expect("eval");
5230
5231            let requests = runtime.drain_hostcall_requests();
5232            assert_eq!(requests.len(), 1);
5233
5234            let captured = Arc::new(Mutex::new(Vec::new()));
5235            let dispatcher = ExtensionDispatcher::new(
5236                Rc::clone(&runtime),
5237                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
5238                Arc::new(HttpConnector::with_defaults()),
5239                Arc::new(NullSession),
5240                Arc::new(TestUiHandler {
5241                    captured: Arc::clone(&captured),
5242                    response_value: Value::Null,
5243                }),
5244                PathBuf::from("."),
5245            );
5246
5247            for request in requests {
5248                dispatcher.dispatch_and_complete(request).await;
5249            }
5250
5251            runtime.tick().await.expect("tick");
5252
5253            let seen = captured
5254                .lock()
5255                .unwrap_or_else(std::sync::PoisonError::into_inner)
5256                .clone();
5257            assert_eq!(seen.len(), 1);
5258            assert_eq!(seen[0].method, "setStatus");
5259            assert_eq!(
5260                seen[0].payload.get("statusKey").and_then(Value::as_str),
5261                Some("key")
5262            );
5263            assert_eq!(
5264                seen[0].payload.get("statusText").and_then(Value::as_str),
5265                Some("hello")
5266            );
5267            assert_eq!(
5268                seen[0].payload.get("text").and_then(Value::as_str),
5269                Some("hello")
5270            );
5271        });
5272    }
5273
5274    #[test]
5275    fn dispatcher_extension_ui_set_widget_includes_widget_lines_and_content() {
5276        futures::executor::block_on(async {
5277            let runtime = Rc::new(
5278                PiJsRuntime::with_clock(DeterministicClock::new(0))
5279                    .await
5280                    .expect("runtime"),
5281            );
5282
5283            runtime
5284                .eval(
5285                    r#"
5286                    const ui = __pi_make_extension_ui(true);
5287                    ui.setWidget("widget", ["a", "b"]);
5288                "#,
5289                )
5290                .await
5291                .expect("eval");
5292
5293            let requests = runtime.drain_hostcall_requests();
5294            assert_eq!(requests.len(), 1);
5295
5296            let captured = Arc::new(Mutex::new(Vec::new()));
5297            let dispatcher = ExtensionDispatcher::new(
5298                Rc::clone(&runtime),
5299                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
5300                Arc::new(HttpConnector::with_defaults()),
5301                Arc::new(NullSession),
5302                Arc::new(TestUiHandler {
5303                    captured: Arc::clone(&captured),
5304                    response_value: Value::Null,
5305                }),
5306                PathBuf::from("."),
5307            );
5308
5309            for request in requests {
5310                dispatcher.dispatch_and_complete(request).await;
5311            }
5312
5313            runtime.tick().await.expect("tick");
5314
5315            let seen = captured
5316                .lock()
5317                .unwrap_or_else(std::sync::PoisonError::into_inner)
5318                .clone();
5319            assert_eq!(seen.len(), 1);
5320            assert_eq!(seen[0].method, "setWidget");
5321            assert_eq!(
5322                seen[0].payload.get("widgetKey").and_then(Value::as_str),
5323                Some("widget")
5324            );
5325            assert_eq!(
5326                seen[0].payload.get("content").and_then(Value::as_str),
5327                Some("a\nb")
5328            );
5329            assert_eq!(
5330                seen[0].payload.get("widgetLines").and_then(Value::as_array),
5331                seen[0].payload.get("lines").and_then(Value::as_array)
5332            );
5333        });
5334    }
5335
5336    #[test]
5337    fn dispatcher_events_hostcall_rejects_promise() {
5338        futures::executor::block_on(async {
5339            let runtime = Rc::new(
5340                PiJsRuntime::with_clock(DeterministicClock::new(0))
5341                    .await
5342                    .expect("runtime"),
5343            );
5344
5345            runtime
5346                .eval(
5347                    r#"
5348                    globalThis.err = null;
5349                    pi.events("setActiveTools", { tools: ["read"] })
5350                        .catch((e) => { globalThis.err = e.code; });
5351                "#,
5352                )
5353                .await
5354                .expect("eval");
5355
5356            let requests = runtime.drain_hostcall_requests();
5357            assert_eq!(requests.len(), 1);
5358
5359            let dispatcher = build_dispatcher(Rc::clone(&runtime));
5360            for request in requests {
5361                dispatcher.dispatch_and_complete(request).await;
5362            }
5363
5364            runtime.tick().await.expect("tick");
5365
5366            runtime
5367                .eval(
5368                    r#"
5369                    if (globalThis.err === null) throw new Error("Promise not rejected");
5370                    if (globalThis.err !== "invalid_request") {
5371                        throw new Error("Wrong error code: " + globalThis.err);
5372                    }
5373                "#,
5374                )
5375                .await
5376                .expect("verify error");
5377        });
5378    }
5379
5380    #[test]
5381    fn dispatcher_events_list_returns_registered_hooks() {
5382        futures::executor::block_on(async {
5383            let runtime = Rc::new(
5384                PiJsRuntime::with_clock(DeterministicClock::new(0))
5385                    .await
5386                    .expect("runtime"),
5387            );
5388
5389            runtime
5390                .eval(
5391                    r#"
5392                    globalThis.eventsList = null;
5393                    __pi_begin_extension("ext.a", { name: "ext.a" });
5394                    pi.on("custom_event", (_payload, _ctx) => {});
5395                    pi.events("list", {}).then((r) => { globalThis.eventsList = r; });
5396                    __pi_end_extension();
5397                "#,
5398                )
5399                .await
5400                .expect("eval");
5401
5402            let requests = runtime.drain_hostcall_requests();
5403            assert_eq!(requests.len(), 1);
5404
5405            let dispatcher = build_dispatcher(Rc::clone(&runtime));
5406            for request in requests {
5407                dispatcher.dispatch_and_complete(request).await;
5408            }
5409
5410            runtime.tick().await.expect("tick");
5411
5412            runtime
5413                .eval(
5414                    r#"
5415                    if (!globalThis.eventsList) throw new Error("Promise not resolved");
5416                    const events = globalThis.eventsList.events;
5417                    if (!Array.isArray(events)) throw new Error("Missing events array");
5418                    if (events.length !== 1 || events[0] !== "custom_event") {
5419                        throw new Error("Wrong events list: " + JSON.stringify(events));
5420                    }
5421                "#,
5422                )
5423                .await
5424                .expect("verify list");
5425        });
5426    }
5427
5428    #[test]
5429    fn dispatcher_session_set_model_resolves_and_persists() {
5430        futures::executor::block_on(async {
5431            let runtime = Rc::new(
5432                PiJsRuntime::with_clock(DeterministicClock::new(0))
5433                    .await
5434                    .expect("runtime"),
5435            );
5436
5437            runtime
5438                .eval(
5439                    r#"
5440                    globalThis.setResult = null;
5441                    pi.session("set_model", { provider: "anthropic", modelId: "claude-sonnet-4-20250514" })
5442                        .then((r) => { globalThis.setResult = r; });
5443                "#,
5444                )
5445                .await
5446                .expect("eval");
5447
5448            let requests = runtime.drain_hostcall_requests();
5449            assert_eq!(requests.len(), 1);
5450
5451            let state = Arc::new(Mutex::new(serde_json::json!({})));
5452            let session = Arc::new(TestSession {
5453                state: Arc::clone(&state),
5454                messages: Arc::new(Mutex::new(Vec::new())),
5455                entries: Arc::new(Mutex::new(Vec::new())),
5456                branch: Arc::new(Mutex::new(Vec::new())),
5457                name: Arc::new(Mutex::new(None)),
5458                custom_entries: Arc::new(Mutex::new(Vec::new())),
5459                labels: Arc::new(Mutex::new(Vec::new())),
5460            });
5461
5462            let dispatcher = ExtensionDispatcher::new(
5463                Rc::clone(&runtime),
5464                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
5465                Arc::new(HttpConnector::with_defaults()),
5466                session,
5467                Arc::new(NullUiHandler),
5468                PathBuf::from("."),
5469            );
5470
5471            for request in requests {
5472                dispatcher.dispatch_and_complete(request).await;
5473            }
5474
5475            while runtime.has_pending() {
5476                runtime.tick().await.expect("tick");
5477                runtime.drain_microtasks().await.expect("microtasks");
5478            }
5479
5480            runtime
5481                .eval(
5482                    r#"
5483                    if (globalThis.setResult !== true) {
5484                        throw new Error("set_model should resolve to true, got: " + JSON.stringify(globalThis.setResult));
5485                    }
5486                "#,
5487                )
5488                .await
5489                .expect("verify set_model result");
5490
5491            let final_state = state
5492                .lock()
5493                .unwrap_or_else(std::sync::PoisonError::into_inner)
5494                .clone();
5495            assert_eq!(
5496                final_state.get("provider").and_then(Value::as_str),
5497                Some("anthropic")
5498            );
5499            assert_eq!(
5500                final_state.get("modelId").and_then(Value::as_str),
5501                Some("claude-sonnet-4-20250514")
5502            );
5503        });
5504    }
5505
5506    #[test]
5507    fn dispatcher_session_get_model_resolves_provider_and_model_id() {
5508        futures::executor::block_on(async {
5509            let runtime = Rc::new(
5510                PiJsRuntime::with_clock(DeterministicClock::new(0))
5511                    .await
5512                    .expect("runtime"),
5513            );
5514
5515            runtime
5516                .eval(
5517                    r#"
5518                    globalThis.model = null;
5519                    pi.session("get_model", {}).then((r) => { globalThis.model = r; });
5520                "#,
5521                )
5522                .await
5523                .expect("eval");
5524
5525            let requests = runtime.drain_hostcall_requests();
5526            assert_eq!(requests.len(), 1);
5527
5528            let state = Arc::new(Mutex::new(serde_json::json!({
5529                "provider": "openai",
5530                "modelId": "gpt-4o",
5531            })));
5532            let session = Arc::new(TestSession {
5533                state: Arc::clone(&state),
5534                messages: Arc::new(Mutex::new(Vec::new())),
5535                entries: Arc::new(Mutex::new(Vec::new())),
5536                branch: Arc::new(Mutex::new(Vec::new())),
5537                name: Arc::new(Mutex::new(None)),
5538                custom_entries: Arc::new(Mutex::new(Vec::new())),
5539                labels: Arc::new(Mutex::new(Vec::new())),
5540            });
5541
5542            let dispatcher = ExtensionDispatcher::new(
5543                Rc::clone(&runtime),
5544                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
5545                Arc::new(HttpConnector::with_defaults()),
5546                session,
5547                Arc::new(NullUiHandler),
5548                PathBuf::from("."),
5549            );
5550
5551            for request in requests {
5552                dispatcher.dispatch_and_complete(request).await;
5553            }
5554
5555            while runtime.has_pending() {
5556                runtime.tick().await.expect("tick");
5557                runtime.drain_microtasks().await.expect("microtasks");
5558            }
5559
5560            runtime
5561                .eval(
5562                    r#"
5563                    if (!globalThis.model) throw new Error("get_model not resolved");
5564                    if (globalThis.model.provider !== "openai") {
5565                        throw new Error("Wrong provider: " + globalThis.model.provider);
5566                    }
5567                    if (globalThis.model.modelId !== "gpt-4o") {
5568                        throw new Error("Wrong modelId: " + globalThis.model.modelId);
5569                    }
5570                "#,
5571                )
5572                .await
5573                .expect("verify get_model result");
5574        });
5575    }
5576
5577    #[test]
5578    fn dispatcher_session_set_model_missing_fields_rejects() {
5579        futures::executor::block_on(async {
5580            let runtime = Rc::new(
5581                PiJsRuntime::with_clock(DeterministicClock::new(0))
5582                    .await
5583                    .expect("runtime"),
5584            );
5585
5586            runtime
5587                .eval(
5588                    r#"
5589                    globalThis.errNoProvider = null;
5590                    globalThis.errNoModelId = null;
5591                    globalThis.errEmpty = null;
5592                    pi.session("set_model", { modelId: "claude-sonnet-4-20250514" })
5593                        .catch((e) => { globalThis.errNoProvider = e.code; });
5594                    pi.session("set_model", { provider: "anthropic" })
5595                        .catch((e) => { globalThis.errNoModelId = e.code; });
5596                    pi.session("set_model", {})
5597                        .catch((e) => { globalThis.errEmpty = e.code; });
5598                "#,
5599                )
5600                .await
5601                .expect("eval");
5602
5603            let requests = runtime.drain_hostcall_requests();
5604            assert_eq!(requests.len(), 3);
5605
5606            let dispatcher = build_dispatcher(Rc::clone(&runtime));
5607            for request in requests {
5608                dispatcher.dispatch_and_complete(request).await;
5609            }
5610
5611            while runtime.has_pending() {
5612                runtime.tick().await.expect("tick");
5613                runtime.drain_microtasks().await.expect("microtasks");
5614            }
5615
5616            runtime
5617                .eval(
5618                    r#"
5619                    if (globalThis.errNoProvider !== "invalid_request") {
5620                        throw new Error("Missing provider should reject: " + globalThis.errNoProvider);
5621                    }
5622                    if (globalThis.errNoModelId !== "invalid_request") {
5623                        throw new Error("Missing modelId should reject: " + globalThis.errNoModelId);
5624                    }
5625                    if (globalThis.errEmpty !== "invalid_request") {
5626                        throw new Error("Empty payload should reject: " + globalThis.errEmpty);
5627                    }
5628                "#,
5629                )
5630                .await
5631                .expect("verify validation errors");
5632        });
5633    }
5634
5635    #[test]
5636    fn dispatcher_session_set_then_get_model_round_trip() {
5637        futures::executor::block_on(async {
5638            let runtime = Rc::new(
5639                PiJsRuntime::with_clock(DeterministicClock::new(0))
5640                    .await
5641                    .expect("runtime"),
5642            );
5643
5644            // Phase 1: set_model
5645            runtime
5646                .eval(
5647                    r#"
5648                    globalThis.setDone = false;
5649                    pi.session("set_model", { provider: "gemini", modelId: "gemini-2.0-flash" })
5650                        .then(() => { globalThis.setDone = true; });
5651                "#,
5652                )
5653                .await
5654                .expect("eval set");
5655
5656            let requests = runtime.drain_hostcall_requests();
5657            assert_eq!(requests.len(), 1);
5658
5659            let state = Arc::new(Mutex::new(serde_json::json!({})));
5660            let session = Arc::new(TestSession {
5661                state: Arc::clone(&state),
5662                messages: Arc::new(Mutex::new(Vec::new())),
5663                entries: Arc::new(Mutex::new(Vec::new())),
5664                branch: Arc::new(Mutex::new(Vec::new())),
5665                name: Arc::new(Mutex::new(None)),
5666                custom_entries: Arc::new(Mutex::new(Vec::new())),
5667                labels: Arc::new(Mutex::new(Vec::new())),
5668            });
5669
5670            let dispatcher = ExtensionDispatcher::new(
5671                Rc::clone(&runtime),
5672                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
5673                Arc::new(HttpConnector::with_defaults()),
5674                session as Arc<dyn ExtensionSession + Send + Sync>,
5675                Arc::new(NullUiHandler),
5676                PathBuf::from("."),
5677            );
5678
5679            for request in requests {
5680                dispatcher.dispatch_and_complete(request).await;
5681            }
5682
5683            while runtime.has_pending() {
5684                runtime.tick().await.expect("tick");
5685                runtime.drain_microtasks().await.expect("microtasks");
5686            }
5687
5688            // Phase 2: get_model
5689            runtime
5690                .eval(
5691                    r#"
5692                    globalThis.model = null;
5693                    pi.session("get_model", {}).then((r) => { globalThis.model = r; });
5694                "#,
5695                )
5696                .await
5697                .expect("eval get");
5698
5699            let requests = runtime.drain_hostcall_requests();
5700            assert_eq!(requests.len(), 1);
5701
5702            for request in requests {
5703                dispatcher.dispatch_and_complete(request).await;
5704            }
5705
5706            while runtime.has_pending() {
5707                runtime.tick().await.expect("tick");
5708                runtime.drain_microtasks().await.expect("microtasks");
5709            }
5710
5711            runtime
5712                .eval(
5713                    r#"
5714                    if (!globalThis.model) throw new Error("get_model not resolved");
5715                    if (globalThis.model.provider !== "gemini") {
5716                        throw new Error("Wrong provider: " + globalThis.model.provider);
5717                    }
5718                    if (globalThis.model.modelId !== "gemini-2.0-flash") {
5719                        throw new Error("Wrong modelId: " + globalThis.model.modelId);
5720                    }
5721                "#,
5722                )
5723                .await
5724                .expect("verify round trip");
5725        });
5726    }
5727
5728    #[test]
5729    fn dispatcher_session_set_thinking_level_resolves() {
5730        futures::executor::block_on(async {
5731            let runtime = Rc::new(
5732                PiJsRuntime::with_clock(DeterministicClock::new(0))
5733                    .await
5734                    .expect("runtime"),
5735            );
5736
5737            runtime
5738                .eval(
5739                    r#"
5740                    globalThis.setDone = false;
5741                    pi.session("set_thinking_level", { level: "high" })
5742                        .then(() => { globalThis.setDone = true; });
5743                "#,
5744                )
5745                .await
5746                .expect("eval");
5747
5748            let requests = runtime.drain_hostcall_requests();
5749            assert_eq!(requests.len(), 1);
5750
5751            let state = Arc::new(Mutex::new(serde_json::json!({})));
5752            let session = Arc::new(TestSession {
5753                state: Arc::clone(&state),
5754                messages: Arc::new(Mutex::new(Vec::new())),
5755                entries: Arc::new(Mutex::new(Vec::new())),
5756                branch: Arc::new(Mutex::new(Vec::new())),
5757                name: Arc::new(Mutex::new(None)),
5758                custom_entries: Arc::new(Mutex::new(Vec::new())),
5759                labels: Arc::new(Mutex::new(Vec::new())),
5760            });
5761
5762            let dispatcher = ExtensionDispatcher::new(
5763                Rc::clone(&runtime),
5764                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
5765                Arc::new(HttpConnector::with_defaults()),
5766                session,
5767                Arc::new(NullUiHandler),
5768                PathBuf::from("."),
5769            );
5770
5771            for request in requests {
5772                dispatcher.dispatch_and_complete(request).await;
5773            }
5774
5775            while runtime.has_pending() {
5776                runtime.tick().await.expect("tick");
5777                runtime.drain_microtasks().await.expect("microtasks");
5778            }
5779
5780            // set_thinking_level resolves to null (not true like set_model)
5781            runtime
5782                .eval(
5783                    r#"
5784                    if (globalThis.setDone !== true) {
5785                        throw new Error("set_thinking_level not resolved");
5786                    }
5787                "#,
5788                )
5789                .await
5790                .expect("verify set_thinking_level");
5791
5792            let final_state = state
5793                .lock()
5794                .unwrap_or_else(std::sync::PoisonError::into_inner)
5795                .clone();
5796            assert_eq!(
5797                final_state.get("thinkingLevel").and_then(Value::as_str),
5798                Some("high")
5799            );
5800        });
5801    }
5802
5803    #[test]
5804    fn dispatcher_session_get_thinking_level_resolves() {
5805        futures::executor::block_on(async {
5806            let runtime = Rc::new(
5807                PiJsRuntime::with_clock(DeterministicClock::new(0))
5808                    .await
5809                    .expect("runtime"),
5810            );
5811
5812            runtime
5813                .eval(
5814                    r#"
5815                    globalThis.level = "__unset__";
5816                    pi.session("get_thinking_level", {}).then((r) => { globalThis.level = r; });
5817                "#,
5818                )
5819                .await
5820                .expect("eval");
5821
5822            let requests = runtime.drain_hostcall_requests();
5823            assert_eq!(requests.len(), 1);
5824
5825            let state = Arc::new(Mutex::new(serde_json::json!({
5826                "thinkingLevel": "medium",
5827            })));
5828            let session = Arc::new(TestSession {
5829                state: Arc::clone(&state),
5830                messages: Arc::new(Mutex::new(Vec::new())),
5831                entries: Arc::new(Mutex::new(Vec::new())),
5832                branch: Arc::new(Mutex::new(Vec::new())),
5833                name: Arc::new(Mutex::new(None)),
5834                custom_entries: Arc::new(Mutex::new(Vec::new())),
5835                labels: Arc::new(Mutex::new(Vec::new())),
5836            });
5837
5838            let dispatcher = ExtensionDispatcher::new(
5839                Rc::clone(&runtime),
5840                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
5841                Arc::new(HttpConnector::with_defaults()),
5842                session,
5843                Arc::new(NullUiHandler),
5844                PathBuf::from("."),
5845            );
5846
5847            for request in requests {
5848                dispatcher.dispatch_and_complete(request).await;
5849            }
5850
5851            while runtime.has_pending() {
5852                runtime.tick().await.expect("tick");
5853                runtime.drain_microtasks().await.expect("microtasks");
5854            }
5855
5856            runtime
5857                .eval(
5858                    r#"
5859                    if (globalThis.level !== "medium") {
5860                        throw new Error("Wrong thinking level: " + JSON.stringify(globalThis.level));
5861                    }
5862                "#,
5863                )
5864                .await
5865                .expect("verify get_thinking_level");
5866        });
5867    }
5868
5869    #[test]
5870    fn dispatcher_session_get_thinking_level_null_when_unset() {
5871        futures::executor::block_on(async {
5872            let runtime = Rc::new(
5873                PiJsRuntime::with_clock(DeterministicClock::new(0))
5874                    .await
5875                    .expect("runtime"),
5876            );
5877
5878            runtime
5879                .eval(
5880                    r#"
5881                    globalThis.level = "__unset__";
5882                    pi.session("get_thinking_level", {}).then((r) => { globalThis.level = r; });
5883                "#,
5884                )
5885                .await
5886                .expect("eval");
5887
5888            let requests = runtime.drain_hostcall_requests();
5889            assert_eq!(requests.len(), 1);
5890
5891            let dispatcher = build_dispatcher(Rc::clone(&runtime));
5892            for request in requests {
5893                dispatcher.dispatch_and_complete(request).await;
5894            }
5895
5896            while runtime.has_pending() {
5897                runtime.tick().await.expect("tick");
5898                runtime.drain_microtasks().await.expect("microtasks");
5899            }
5900
5901            runtime
5902                .eval(
5903                    r#"
5904                    if (globalThis.level !== null) {
5905                        throw new Error("Unset thinking level should be null, got: " + JSON.stringify(globalThis.level));
5906                    }
5907                "#,
5908                )
5909                .await
5910                .expect("verify null thinking level");
5911        });
5912    }
5913
5914    #[test]
5915    fn dispatcher_session_set_thinking_level_missing_level_rejects() {
5916        futures::executor::block_on(async {
5917            let runtime = Rc::new(
5918                PiJsRuntime::with_clock(DeterministicClock::new(0))
5919                    .await
5920                    .expect("runtime"),
5921            );
5922
5923            runtime
5924                .eval(
5925                    r#"
5926                    globalThis.err = null;
5927                    pi.session("set_thinking_level", {})
5928                        .catch((e) => { globalThis.err = e.code; });
5929                "#,
5930                )
5931                .await
5932                .expect("eval");
5933
5934            let requests = runtime.drain_hostcall_requests();
5935            assert_eq!(requests.len(), 1);
5936
5937            let dispatcher = build_dispatcher(Rc::clone(&runtime));
5938            for request in requests {
5939                dispatcher.dispatch_and_complete(request).await;
5940            }
5941
5942            while runtime.has_pending() {
5943                runtime.tick().await.expect("tick");
5944                runtime.drain_microtasks().await.expect("microtasks");
5945            }
5946
5947            runtime
5948                .eval(
5949                    r#"
5950                    if (globalThis.err !== "invalid_request") {
5951                        throw new Error("Missing level should reject: " + globalThis.err);
5952                    }
5953                "#,
5954                )
5955                .await
5956                .expect("verify validation error");
5957        });
5958    }
5959
5960    #[test]
5961    fn dispatcher_session_set_then_get_thinking_level_round_trip() {
5962        futures::executor::block_on(async {
5963            let runtime = Rc::new(
5964                PiJsRuntime::with_clock(DeterministicClock::new(0))
5965                    .await
5966                    .expect("runtime"),
5967            );
5968
5969            // Phase 1: set
5970            runtime
5971                .eval(
5972                    r#"
5973                    globalThis.setDone = false;
5974                    pi.session("set_thinking_level", { level: "low" })
5975                        .then(() => { globalThis.setDone = true; });
5976                "#,
5977                )
5978                .await
5979                .expect("eval set");
5980
5981            let requests = runtime.drain_hostcall_requests();
5982            assert_eq!(requests.len(), 1);
5983
5984            let state = Arc::new(Mutex::new(serde_json::json!({})));
5985            let session = Arc::new(TestSession {
5986                state: Arc::clone(&state),
5987                messages: Arc::new(Mutex::new(Vec::new())),
5988                entries: Arc::new(Mutex::new(Vec::new())),
5989                branch: Arc::new(Mutex::new(Vec::new())),
5990                name: Arc::new(Mutex::new(None)),
5991                custom_entries: Arc::new(Mutex::new(Vec::new())),
5992                labels: Arc::new(Mutex::new(Vec::new())),
5993            });
5994
5995            let dispatcher = ExtensionDispatcher::new(
5996                Rc::clone(&runtime),
5997                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
5998                Arc::new(HttpConnector::with_defaults()),
5999                session as Arc<dyn ExtensionSession + Send + Sync>,
6000                Arc::new(NullUiHandler),
6001                PathBuf::from("."),
6002            );
6003
6004            for request in requests {
6005                dispatcher.dispatch_and_complete(request).await;
6006            }
6007
6008            while runtime.has_pending() {
6009                runtime.tick().await.expect("tick");
6010                runtime.drain_microtasks().await.expect("microtasks");
6011            }
6012
6013            // Phase 2: get
6014            runtime
6015                .eval(
6016                    r#"
6017                    globalThis.level = "__unset__";
6018                    pi.session("get_thinking_level", {}).then((r) => { globalThis.level = r; });
6019                "#,
6020                )
6021                .await
6022                .expect("eval get");
6023
6024            let requests = runtime.drain_hostcall_requests();
6025            assert_eq!(requests.len(), 1);
6026
6027            for request in requests {
6028                dispatcher.dispatch_and_complete(request).await;
6029            }
6030
6031            while runtime.has_pending() {
6032                runtime.tick().await.expect("tick");
6033                runtime.drain_microtasks().await.expect("microtasks");
6034            }
6035
6036            runtime
6037                .eval(
6038                    r#"
6039                    if (globalThis.level !== "low") {
6040                        throw new Error("Round trip failed, got: " + JSON.stringify(globalThis.level));
6041                    }
6042                "#,
6043                )
6044                .await
6045                .expect("verify round trip");
6046        });
6047    }
6048
6049    #[test]
6050    fn dispatcher_session_model_ops_accept_camel_case_aliases() {
6051        futures::executor::block_on(async {
6052            let runtime = Rc::new(
6053                PiJsRuntime::with_clock(DeterministicClock::new(0))
6054                    .await
6055                    .expect("runtime"),
6056            );
6057
6058            runtime
6059                .eval(
6060                    r#"
6061                    globalThis.setDone = false;
6062                    globalThis.model = null;
6063                    globalThis.thinkingSet = false;
6064                    globalThis.thinking = "__unset__";
6065                    pi.session("setmodel", { provider: "azure", modelId: "gpt-4" })
6066                        .then(() => { globalThis.setDone = true; });
6067                    pi.session("getmodel", {}).then((r) => { globalThis.model = r; });
6068                    pi.session("setthinkinglevel", { level: "high" })
6069                        .then(() => { globalThis.thinkingSet = true; });
6070                    pi.session("getthinkinglevel", {}).then((r) => { globalThis.thinking = r; });
6071                "#,
6072                )
6073                .await
6074                .expect("eval");
6075
6076            let requests = runtime.drain_hostcall_requests();
6077            assert_eq!(requests.len(), 4);
6078
6079            let state = Arc::new(Mutex::new(serde_json::json!({})));
6080            let session = Arc::new(TestSession {
6081                state: Arc::clone(&state),
6082                messages: Arc::new(Mutex::new(Vec::new())),
6083                entries: Arc::new(Mutex::new(Vec::new())),
6084                branch: Arc::new(Mutex::new(Vec::new())),
6085                name: Arc::new(Mutex::new(None)),
6086                custom_entries: Arc::new(Mutex::new(Vec::new())),
6087                labels: Arc::new(Mutex::new(Vec::new())),
6088            });
6089
6090            let dispatcher = ExtensionDispatcher::new(
6091                Rc::clone(&runtime),
6092                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
6093                Arc::new(HttpConnector::with_defaults()),
6094                session as Arc<dyn ExtensionSession + Send + Sync>,
6095                Arc::new(NullUiHandler),
6096                PathBuf::from("."),
6097            );
6098
6099            for request in requests {
6100                dispatcher.dispatch_and_complete(request).await;
6101            }
6102
6103            while runtime.has_pending() {
6104                runtime.tick().await.expect("tick");
6105                runtime.drain_microtasks().await.expect("microtasks");
6106            }
6107
6108            runtime
6109                .eval(
6110                    r#"
6111                    if (!globalThis.setDone) throw new Error("setmodel not resolved");
6112                    if (!globalThis.thinkingSet) throw new Error("setthinkinglevel not resolved");
6113                "#,
6114                )
6115                .await
6116                .expect("verify camelCase aliases");
6117        });
6118    }
6119
6120    #[test]
6121    fn dispatcher_session_set_model_accepts_model_id_snake_case() {
6122        futures::executor::block_on(async {
6123            let runtime = Rc::new(
6124                PiJsRuntime::with_clock(DeterministicClock::new(0))
6125                    .await
6126                    .expect("runtime"),
6127            );
6128
6129            runtime
6130                .eval(
6131                    r#"
6132                    globalThis.setDone = false;
6133                    pi.session("set_model", { provider: "anthropic", model_id: "claude-opus-4-20250514" })
6134                        .then(() => { globalThis.setDone = true; });
6135                "#,
6136                )
6137                .await
6138                .expect("eval");
6139
6140            let requests = runtime.drain_hostcall_requests();
6141            assert_eq!(requests.len(), 1);
6142
6143            let state = Arc::new(Mutex::new(serde_json::json!({})));
6144            let session = Arc::new(TestSession {
6145                state: Arc::clone(&state),
6146                messages: Arc::new(Mutex::new(Vec::new())),
6147                entries: Arc::new(Mutex::new(Vec::new())),
6148                branch: Arc::new(Mutex::new(Vec::new())),
6149                name: Arc::new(Mutex::new(None)),
6150                custom_entries: Arc::new(Mutex::new(Vec::new())),
6151                labels: Arc::new(Mutex::new(Vec::new())),
6152            });
6153
6154            let dispatcher = ExtensionDispatcher::new(
6155                Rc::clone(&runtime),
6156                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
6157                Arc::new(HttpConnector::with_defaults()),
6158                session,
6159                Arc::new(NullUiHandler),
6160                PathBuf::from("."),
6161            );
6162
6163            for request in requests {
6164                dispatcher.dispatch_and_complete(request).await;
6165            }
6166
6167            while runtime.has_pending() {
6168                runtime.tick().await.expect("tick");
6169                runtime.drain_microtasks().await.expect("microtasks");
6170            }
6171
6172            runtime
6173                .eval(
6174                    r#"
6175                    if (!globalThis.setDone) throw new Error("set_model with model_id not resolved");
6176                "#,
6177                )
6178                .await
6179                .expect("verify model_id snake_case");
6180
6181            let final_state = state
6182                .lock()
6183                .unwrap_or_else(std::sync::PoisonError::into_inner)
6184                .clone();
6185            assert_eq!(
6186                final_state.get("modelId").and_then(Value::as_str),
6187                Some("claude-opus-4-20250514")
6188            );
6189        });
6190    }
6191
6192    #[test]
6193    fn dispatcher_session_set_thinking_level_accepts_alt_keys() {
6194        futures::executor::block_on(async {
6195            let runtime = Rc::new(
6196                PiJsRuntime::with_clock(DeterministicClock::new(0))
6197                    .await
6198                    .expect("runtime"),
6199            );
6200
6201            // Test thinkingLevel key
6202            runtime
6203                .eval(
6204                    r#"
6205                    globalThis.done1 = false;
6206                    globalThis.done2 = false;
6207                    pi.session("set_thinking_level", { thinkingLevel: "medium" })
6208                        .then(() => { globalThis.done1 = true; });
6209                    pi.session("set_thinking_level", { thinking_level: "low" })
6210                        .then(() => { globalThis.done2 = true; });
6211                "#,
6212                )
6213                .await
6214                .expect("eval");
6215
6216            let requests = runtime.drain_hostcall_requests();
6217            assert_eq!(requests.len(), 2);
6218
6219            let state = Arc::new(Mutex::new(serde_json::json!({})));
6220            let session = Arc::new(TestSession {
6221                state: Arc::clone(&state),
6222                messages: Arc::new(Mutex::new(Vec::new())),
6223                entries: Arc::new(Mutex::new(Vec::new())),
6224                branch: Arc::new(Mutex::new(Vec::new())),
6225                name: Arc::new(Mutex::new(None)),
6226                custom_entries: Arc::new(Mutex::new(Vec::new())),
6227                labels: Arc::new(Mutex::new(Vec::new())),
6228            });
6229
6230            let dispatcher = ExtensionDispatcher::new(
6231                Rc::clone(&runtime),
6232                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
6233                Arc::new(HttpConnector::with_defaults()),
6234                session,
6235                Arc::new(NullUiHandler),
6236                PathBuf::from("."),
6237            );
6238
6239            for request in requests {
6240                dispatcher.dispatch_and_complete(request).await;
6241            }
6242
6243            while runtime.has_pending() {
6244                runtime.tick().await.expect("tick");
6245                runtime.drain_microtasks().await.expect("microtasks");
6246            }
6247
6248            runtime
6249                .eval(
6250                    r#"
6251                    if (!globalThis.done1) throw new Error("thinkingLevel key not resolved");
6252                    if (!globalThis.done2) throw new Error("thinking_level key not resolved");
6253                "#,
6254                )
6255                .await
6256                .expect("verify alt keys");
6257
6258            // Last write wins, so "low" should be the final value
6259            let final_state = state
6260                .lock()
6261                .unwrap_or_else(std::sync::PoisonError::into_inner)
6262                .clone();
6263            assert_eq!(
6264                final_state.get("thinkingLevel").and_then(Value::as_str),
6265                Some("low")
6266            );
6267        });
6268    }
6269
6270    #[test]
6271    fn dispatcher_session_get_model_null_when_unset() {
6272        futures::executor::block_on(async {
6273            let runtime = Rc::new(
6274                PiJsRuntime::with_clock(DeterministicClock::new(0))
6275                    .await
6276                    .expect("runtime"),
6277            );
6278
6279            runtime
6280                .eval(
6281                    r#"
6282                    globalThis.model = "__unset__";
6283                    pi.session("get_model", {}).then((r) => { globalThis.model = r; });
6284                "#,
6285                )
6286                .await
6287                .expect("eval");
6288
6289            let requests = runtime.drain_hostcall_requests();
6290            assert_eq!(requests.len(), 1);
6291
6292            // NullSession returns (None, None) for get_model
6293            let dispatcher = build_dispatcher(Rc::clone(&runtime));
6294            for request in requests {
6295                dispatcher.dispatch_and_complete(request).await;
6296            }
6297
6298            while runtime.has_pending() {
6299                runtime.tick().await.expect("tick");
6300                runtime.drain_microtasks().await.expect("microtasks");
6301            }
6302
6303            runtime
6304                .eval(
6305                    r#"
6306                    if (!globalThis.model) throw new Error("get_model not resolved");
6307                    if (globalThis.model.provider !== null) {
6308                        throw new Error("Unset provider should be null, got: " + JSON.stringify(globalThis.model.provider));
6309                    }
6310                    if (globalThis.model.modelId !== null) {
6311                        throw new Error("Unset modelId should be null, got: " + JSON.stringify(globalThis.model.modelId));
6312                    }
6313                "#,
6314                )
6315                .await
6316                .expect("verify null model");
6317        });
6318    }
6319
6320    // ---- set_label tests ----
6321
6322    #[test]
6323    fn dispatcher_session_set_label_resolves_and_persists() {
6324        futures::executor::block_on(async {
6325            let runtime = Rc::new(
6326                PiJsRuntime::with_clock(DeterministicClock::new(0))
6327                    .await
6328                    .expect("runtime"),
6329            );
6330
6331            runtime
6332                .eval(
6333                    r#"
6334                    globalThis.result = "__unset__";
6335                    pi.session("set_label", { targetId: "msg-42", label: "important" })
6336                        .then((r) => { globalThis.result = r; });
6337                "#,
6338                )
6339                .await
6340                .expect("eval");
6341
6342            let requests = runtime.drain_hostcall_requests();
6343            assert_eq!(requests.len(), 1);
6344
6345            let labels: Arc<Mutex<Vec<LabelEntry>>> = Arc::new(Mutex::new(Vec::new()));
6346            let session = Arc::new(TestSession {
6347                state: Arc::new(Mutex::new(serde_json::json!({}))),
6348                messages: Arc::new(Mutex::new(Vec::new())),
6349                entries: Arc::new(Mutex::new(Vec::new())),
6350                branch: Arc::new(Mutex::new(Vec::new())),
6351                name: Arc::new(Mutex::new(None)),
6352                custom_entries: Arc::new(Mutex::new(Vec::new())),
6353                labels: Arc::clone(&labels),
6354            });
6355
6356            let dispatcher = ExtensionDispatcher::new(
6357                Rc::clone(&runtime),
6358                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
6359                Arc::new(HttpConnector::with_defaults()),
6360                session,
6361                Arc::new(NullUiHandler),
6362                PathBuf::from("."),
6363            );
6364
6365            for request in requests {
6366                dispatcher.dispatch_and_complete(request).await;
6367            }
6368
6369            while runtime.has_pending() {
6370                runtime.tick().await.expect("tick");
6371                runtime.drain_microtasks().await.expect("microtasks");
6372            }
6373
6374            // Verify set_label was called with correct args
6375            let captured = labels
6376                .lock()
6377                .unwrap_or_else(std::sync::PoisonError::into_inner);
6378            assert_eq!(captured.len(), 1);
6379            assert_eq!(captured[0].0, "msg-42");
6380            assert_eq!(captured[0].1.as_deref(), Some("important"));
6381            drop(captured);
6382        });
6383    }
6384
6385    #[test]
6386    fn dispatcher_session_set_label_remove_label_with_null() {
6387        futures::executor::block_on(async {
6388            let runtime = Rc::new(
6389                PiJsRuntime::with_clock(DeterministicClock::new(0))
6390                    .await
6391                    .expect("runtime"),
6392            );
6393
6394            runtime
6395                .eval(
6396                    r#"
6397                    globalThis.result = "__unset__";
6398                    pi.session("set_label", { targetId: "msg-99" })
6399                        .then((r) => { globalThis.result = r; });
6400                "#,
6401                )
6402                .await
6403                .expect("eval");
6404
6405            let requests = runtime.drain_hostcall_requests();
6406            assert_eq!(requests.len(), 1);
6407
6408            let labels: Arc<Mutex<Vec<LabelEntry>>> = Arc::new(Mutex::new(Vec::new()));
6409            let session = Arc::new(TestSession {
6410                state: Arc::new(Mutex::new(serde_json::json!({}))),
6411                messages: Arc::new(Mutex::new(Vec::new())),
6412                entries: Arc::new(Mutex::new(Vec::new())),
6413                branch: Arc::new(Mutex::new(Vec::new())),
6414                name: Arc::new(Mutex::new(None)),
6415                custom_entries: Arc::new(Mutex::new(Vec::new())),
6416                labels: Arc::clone(&labels),
6417            });
6418
6419            let dispatcher = ExtensionDispatcher::new(
6420                Rc::clone(&runtime),
6421                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
6422                Arc::new(HttpConnector::with_defaults()),
6423                session,
6424                Arc::new(NullUiHandler),
6425                PathBuf::from("."),
6426            );
6427
6428            for request in requests {
6429                dispatcher.dispatch_and_complete(request).await;
6430            }
6431
6432            while runtime.has_pending() {
6433                runtime.tick().await.expect("tick");
6434                runtime.drain_microtasks().await.expect("microtasks");
6435            }
6436
6437            // Verify set_label was called with None label (removal)
6438            let captured = labels
6439                .lock()
6440                .unwrap_or_else(std::sync::PoisonError::into_inner);
6441            assert_eq!(captured.len(), 1);
6442            assert_eq!(captured[0].0, "msg-99");
6443            assert!(captured[0].1.is_none());
6444            drop(captured);
6445        });
6446    }
6447
6448    #[test]
6449    fn dispatcher_session_set_label_missing_target_id_rejects() {
6450        futures::executor::block_on(async {
6451            let runtime = Rc::new(
6452                PiJsRuntime::with_clock(DeterministicClock::new(0))
6453                    .await
6454                    .expect("runtime"),
6455            );
6456
6457            runtime
6458                .eval(
6459                    r#"
6460                    globalThis.errMsg = "";
6461                    pi.session("set_label", { label: "orphaned" })
6462                        .then(() => { globalThis.errMsg = "should_not_resolve"; })
6463                        .catch((e) => { globalThis.errMsg = e.message || String(e); });
6464                "#,
6465                )
6466                .await
6467                .expect("eval");
6468
6469            let requests = runtime.drain_hostcall_requests();
6470            assert_eq!(requests.len(), 1);
6471
6472            let dispatcher = build_dispatcher(Rc::clone(&runtime));
6473            for request in requests {
6474                dispatcher.dispatch_and_complete(request).await;
6475            }
6476
6477            while runtime.has_pending() {
6478                runtime.tick().await.expect("tick");
6479                runtime.drain_microtasks().await.expect("microtasks");
6480            }
6481
6482            runtime
6483                .eval(
6484                    r#"
6485                    if (!globalThis.errMsg || globalThis.errMsg === "should_not_resolve") {
6486                        throw new Error("Expected rejection, got: " + globalThis.errMsg);
6487                    }
6488                    if (!globalThis.errMsg.includes("targetId")) {
6489                        throw new Error("Expected error about targetId, got: " + globalThis.errMsg);
6490                    }
6491                "#,
6492                )
6493                .await
6494                .expect("verify rejection");
6495        });
6496    }
6497
6498    #[test]
6499    fn dispatcher_session_set_label_accepts_snake_case_target_id() {
6500        futures::executor::block_on(async {
6501            let runtime = Rc::new(
6502                PiJsRuntime::with_clock(DeterministicClock::new(0))
6503                    .await
6504                    .expect("runtime"),
6505            );
6506
6507            runtime
6508                .eval(
6509                    r#"
6510                    globalThis.result = "__unset__";
6511                    pi.session("set_label", { target_id: "msg-77", label: "reviewed" })
6512                        .then((r) => { globalThis.result = r; });
6513                "#,
6514                )
6515                .await
6516                .expect("eval");
6517
6518            let requests = runtime.drain_hostcall_requests();
6519            assert_eq!(requests.len(), 1);
6520
6521            let labels: Arc<Mutex<Vec<LabelEntry>>> = Arc::new(Mutex::new(Vec::new()));
6522            let session = Arc::new(TestSession {
6523                state: Arc::new(Mutex::new(serde_json::json!({}))),
6524                messages: Arc::new(Mutex::new(Vec::new())),
6525                entries: Arc::new(Mutex::new(Vec::new())),
6526                branch: Arc::new(Mutex::new(Vec::new())),
6527                name: Arc::new(Mutex::new(None)),
6528                custom_entries: Arc::new(Mutex::new(Vec::new())),
6529                labels: Arc::clone(&labels),
6530            });
6531
6532            let dispatcher = ExtensionDispatcher::new(
6533                Rc::clone(&runtime),
6534                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
6535                Arc::new(HttpConnector::with_defaults()),
6536                session,
6537                Arc::new(NullUiHandler),
6538                PathBuf::from("."),
6539            );
6540
6541            for request in requests {
6542                dispatcher.dispatch_and_complete(request).await;
6543            }
6544
6545            while runtime.has_pending() {
6546                runtime.tick().await.expect("tick");
6547                runtime.drain_microtasks().await.expect("microtasks");
6548            }
6549
6550            let captured = labels
6551                .lock()
6552                .unwrap_or_else(std::sync::PoisonError::into_inner);
6553            assert_eq!(captured.len(), 1);
6554            assert_eq!(captured[0].0, "msg-77");
6555            assert_eq!(captured[0].1.as_deref(), Some("reviewed"));
6556            drop(captured);
6557        });
6558    }
6559
6560    #[test]
6561    fn dispatcher_session_set_label_camel_case_op_alias() {
6562        futures::executor::block_on(async {
6563            let runtime = Rc::new(
6564                PiJsRuntime::with_clock(DeterministicClock::new(0))
6565                    .await
6566                    .expect("runtime"),
6567            );
6568
6569            // Use "setLabel" style (gets lowercased to "setlabel" which matches)
6570            runtime
6571                .eval(
6572                    r#"
6573                    globalThis.result = "__unset__";
6574                    pi.session("setLabel", { targetId: "entry-5", label: "flagged" })
6575                        .then((r) => { globalThis.result = r; });
6576                "#,
6577                )
6578                .await
6579                .expect("eval");
6580
6581            let requests = runtime.drain_hostcall_requests();
6582            assert_eq!(requests.len(), 1);
6583
6584            let labels: Arc<Mutex<Vec<LabelEntry>>> = Arc::new(Mutex::new(Vec::new()));
6585            let session = Arc::new(TestSession {
6586                state: Arc::new(Mutex::new(serde_json::json!({}))),
6587                messages: Arc::new(Mutex::new(Vec::new())),
6588                entries: Arc::new(Mutex::new(Vec::new())),
6589                branch: Arc::new(Mutex::new(Vec::new())),
6590                name: Arc::new(Mutex::new(None)),
6591                custom_entries: Arc::new(Mutex::new(Vec::new())),
6592                labels: Arc::clone(&labels),
6593            });
6594
6595            let dispatcher = ExtensionDispatcher::new(
6596                Rc::clone(&runtime),
6597                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
6598                Arc::new(HttpConnector::with_defaults()),
6599                session,
6600                Arc::new(NullUiHandler),
6601                PathBuf::from("."),
6602            );
6603
6604            for request in requests {
6605                dispatcher.dispatch_and_complete(request).await;
6606            }
6607
6608            while runtime.has_pending() {
6609                runtime.tick().await.expect("tick");
6610                runtime.drain_microtasks().await.expect("microtasks");
6611            }
6612
6613            let captured = labels
6614                .lock()
6615                .unwrap_or_else(std::sync::PoisonError::into_inner);
6616            assert_eq!(captured.len(), 1);
6617            assert_eq!(captured[0].0, "entry-5");
6618            assert_eq!(captured[0].1.as_deref(), Some("flagged"));
6619            drop(captured);
6620        });
6621    }
6622
6623    // ---- Tool conformance tests ----
6624
6625    #[test]
6626    fn dispatcher_tool_write_creates_file_and_resolves() {
6627        futures::executor::block_on(async {
6628            let temp_dir = tempfile::tempdir().expect("tempdir");
6629
6630            let runtime = Rc::new(
6631                PiJsRuntime::with_clock(DeterministicClock::new(0))
6632                    .await
6633                    .expect("runtime"),
6634            );
6635
6636            let file_path = temp_dir.path().join("output.txt");
6637            let file_path_str = file_path.display().to_string().replace('\\', "\\\\");
6638            let script = format!(
6639                r#"
6640                globalThis.result = null;
6641                pi.tool("write", {{ path: "{file_path_str}", content: "written by extension" }})
6642                    .then((r) => {{ globalThis.result = r; }});
6643            "#
6644            );
6645            runtime.eval(&script).await.expect("eval");
6646
6647            let requests = runtime.drain_hostcall_requests();
6648            assert_eq!(requests.len(), 1);
6649
6650            let dispatcher = ExtensionDispatcher::new(
6651                Rc::clone(&runtime),
6652                Arc::new(ToolRegistry::new(&["write"], temp_dir.path(), None)),
6653                Arc::new(HttpConnector::with_defaults()),
6654                Arc::new(NullSession),
6655                Arc::new(NullUiHandler),
6656                temp_dir.path().to_path_buf(),
6657            );
6658
6659            for request in requests {
6660                dispatcher.dispatch_and_complete(request).await;
6661            }
6662
6663            while runtime.has_pending() {
6664                runtime.tick().await.expect("tick");
6665                runtime.drain_microtasks().await.expect("microtasks");
6666            }
6667
6668            // Verify file was created
6669            assert!(file_path.exists());
6670            let content = std::fs::read_to_string(&file_path).expect("read file");
6671            assert_eq!(content, "written by extension");
6672        });
6673    }
6674
6675    #[test]
6676    fn dispatcher_tool_ls_lists_directory() {
6677        futures::executor::block_on(async {
6678            let temp_dir = tempfile::tempdir().expect("tempdir");
6679            std::fs::write(temp_dir.path().join("alpha.txt"), "a").expect("write");
6680            std::fs::write(temp_dir.path().join("beta.txt"), "b").expect("write");
6681
6682            let runtime = Rc::new(
6683                PiJsRuntime::with_clock(DeterministicClock::new(0))
6684                    .await
6685                    .expect("runtime"),
6686            );
6687
6688            runtime
6689                .eval(
6690                    r#"
6691                    globalThis.result = null;
6692                    pi.tool("ls", { path: "." })
6693                        .then((r) => { globalThis.result = r; });
6694                "#,
6695                )
6696                .await
6697                .expect("eval");
6698
6699            let requests = runtime.drain_hostcall_requests();
6700            assert_eq!(requests.len(), 1);
6701
6702            let dispatcher = ExtensionDispatcher::new(
6703                Rc::clone(&runtime),
6704                Arc::new(ToolRegistry::new(&["ls"], temp_dir.path(), None)),
6705                Arc::new(HttpConnector::with_defaults()),
6706                Arc::new(NullSession),
6707                Arc::new(NullUiHandler),
6708                temp_dir.path().to_path_buf(),
6709            );
6710
6711            for request in requests {
6712                dispatcher.dispatch_and_complete(request).await;
6713            }
6714
6715            while runtime.has_pending() {
6716                runtime.tick().await.expect("tick");
6717                runtime.drain_microtasks().await.expect("microtasks");
6718            }
6719
6720            runtime
6721                .eval(
6722                    r#"
6723                    if (globalThis.result === null) throw new Error("ls not resolved");
6724                    let s = JSON.stringify(globalThis.result);
6725                    if (!s.includes("alpha.txt") || !s.includes("beta.txt")) {
6726                        throw new Error("Missing files in ls output: " + s);
6727                    }
6728                "#,
6729                )
6730                .await
6731                .expect("verify ls result");
6732        });
6733    }
6734
6735    #[test]
6736    fn dispatcher_tool_grep_searches_content() {
6737        futures::executor::block_on(async {
6738            let temp_dir = tempfile::tempdir().expect("tempdir");
6739            std::fs::write(
6740                temp_dir.path().join("data.txt"),
6741                "line one\nline two\nline three",
6742            )
6743            .expect("write");
6744
6745            let runtime = Rc::new(
6746                PiJsRuntime::with_clock(DeterministicClock::new(0))
6747                    .await
6748                    .expect("runtime"),
6749            );
6750
6751            let dir = temp_dir.path().display().to_string().replace('\\', "\\\\");
6752            let script = format!(
6753                r#"
6754                globalThis.result = null;
6755                pi.tool("grep", {{ pattern: "two", path: "{dir}" }})
6756                    .then((r) => {{ globalThis.result = r; }});
6757            "#
6758            );
6759            runtime.eval(&script).await.expect("eval");
6760
6761            let requests = runtime.drain_hostcall_requests();
6762            assert_eq!(requests.len(), 1);
6763
6764            let dispatcher = ExtensionDispatcher::new(
6765                Rc::clone(&runtime),
6766                Arc::new(ToolRegistry::new(&["grep"], temp_dir.path(), None)),
6767                Arc::new(HttpConnector::with_defaults()),
6768                Arc::new(NullSession),
6769                Arc::new(NullUiHandler),
6770                temp_dir.path().to_path_buf(),
6771            );
6772
6773            for request in requests {
6774                dispatcher.dispatch_and_complete(request).await;
6775            }
6776
6777            while runtime.has_pending() {
6778                runtime.tick().await.expect("tick");
6779                runtime.drain_microtasks().await.expect("microtasks");
6780            }
6781
6782            runtime
6783                .eval(
6784                    r#"
6785                    if (globalThis.result === null) throw new Error("grep not resolved");
6786                    let s = JSON.stringify(globalThis.result);
6787                    if (!s.includes("two")) {
6788                        throw new Error("grep should find 'two': " + s);
6789                    }
6790                "#,
6791                )
6792                .await
6793                .expect("verify grep result");
6794        });
6795    }
6796
6797    #[test]
6798    fn dispatcher_tool_edit_modifies_file_content() {
6799        futures::executor::block_on(async {
6800            let temp_dir = tempfile::tempdir().expect("tempdir");
6801            std::fs::write(temp_dir.path().join("target.txt"), "old text here").expect("write");
6802
6803            let runtime = Rc::new(
6804                PiJsRuntime::with_clock(DeterministicClock::new(0))
6805                    .await
6806                    .expect("runtime"),
6807            );
6808
6809            runtime
6810                .eval(
6811                    r#"
6812                    globalThis.result = null;
6813                    pi.tool("edit", { path: "target.txt", oldText: "old text", newText: "new text" })
6814                        .then((r) => { globalThis.result = r; });
6815                "#,
6816                )
6817                .await
6818                .expect("eval");
6819
6820            let requests = runtime.drain_hostcall_requests();
6821            assert_eq!(requests.len(), 1);
6822
6823            let dispatcher = ExtensionDispatcher::new(
6824                Rc::clone(&runtime),
6825                Arc::new(ToolRegistry::new(&["edit"], temp_dir.path(), None)),
6826                Arc::new(HttpConnector::with_defaults()),
6827                Arc::new(NullSession),
6828                Arc::new(NullUiHandler),
6829                temp_dir.path().to_path_buf(),
6830            );
6831
6832            for request in requests {
6833                dispatcher.dispatch_and_complete(request).await;
6834            }
6835
6836            while runtime.has_pending() {
6837                runtime.tick().await.expect("tick");
6838                runtime.drain_microtasks().await.expect("microtasks");
6839            }
6840
6841            let content =
6842                std::fs::read_to_string(temp_dir.path().join("target.txt")).expect("read file");
6843            assert!(
6844                content.contains("new text"),
6845                "Expected edited content, got: {content}"
6846            );
6847        });
6848    }
6849
6850    #[test]
6851    fn dispatcher_tool_find_discovers_files() {
6852        futures::executor::block_on(async {
6853            let temp_dir = tempfile::tempdir().expect("tempdir");
6854            std::fs::write(temp_dir.path().join("code.rs"), "fn main(){}").expect("write");
6855            std::fs::write(temp_dir.path().join("data.json"), "{}").expect("write");
6856
6857            let runtime = Rc::new(
6858                PiJsRuntime::with_clock(DeterministicClock::new(0))
6859                    .await
6860                    .expect("runtime"),
6861            );
6862
6863            runtime
6864                .eval(
6865                    r#"
6866                    globalThis.result = null;
6867                    pi.tool("find", { pattern: "*.rs" })
6868                        .then((r) => { globalThis.result = r; });
6869                "#,
6870                )
6871                .await
6872                .expect("eval");
6873
6874            let requests = runtime.drain_hostcall_requests();
6875            assert_eq!(requests.len(), 1);
6876
6877            let dispatcher = ExtensionDispatcher::new(
6878                Rc::clone(&runtime),
6879                Arc::new(ToolRegistry::new(&["find"], temp_dir.path(), None)),
6880                Arc::new(HttpConnector::with_defaults()),
6881                Arc::new(NullSession),
6882                Arc::new(NullUiHandler),
6883                temp_dir.path().to_path_buf(),
6884            );
6885
6886            for request in requests {
6887                dispatcher.dispatch_and_complete(request).await;
6888            }
6889
6890            while runtime.has_pending() {
6891                runtime.tick().await.expect("tick");
6892                runtime.drain_microtasks().await.expect("microtasks");
6893            }
6894
6895            runtime
6896                .eval(
6897                    r#"
6898                    if (globalThis.result === null) throw new Error("find not resolved");
6899                    let s = JSON.stringify(globalThis.result);
6900                    if (!s.includes("code.rs")) {
6901                        throw new Error("find should discover code.rs: " + s);
6902                    }
6903                    if (s.includes("data.json")) {
6904                        throw new Error("find *.rs should not include data.json: " + s);
6905                    }
6906                "#,
6907                )
6908                .await
6909                .expect("verify find result");
6910        });
6911    }
6912
6913    #[test]
6914    fn dispatcher_tool_multiple_tools_sequentially() {
6915        futures::executor::block_on(async {
6916            let temp_dir = tempfile::tempdir().expect("tempdir");
6917            std::fs::write(temp_dir.path().join("file.txt"), "hello").expect("write");
6918
6919            let runtime = Rc::new(
6920                PiJsRuntime::with_clock(DeterministicClock::new(0))
6921                    .await
6922                    .expect("runtime"),
6923            );
6924
6925            // Queue two tool calls
6926            runtime
6927                .eval(
6928                    r#"
6929                    globalThis.readResult = null;
6930                    globalThis.lsResult = null;
6931                    pi.tool("read", { path: "file.txt" })
6932                        .then((r) => { globalThis.readResult = r; });
6933                    pi.tool("ls", { path: "." })
6934                        .then((r) => { globalThis.lsResult = r; });
6935                "#,
6936                )
6937                .await
6938                .expect("eval");
6939
6940            let requests = runtime.drain_hostcall_requests();
6941            assert_eq!(requests.len(), 2);
6942
6943            let dispatcher = ExtensionDispatcher::new(
6944                Rc::clone(&runtime),
6945                Arc::new(ToolRegistry::new(&["read", "ls"], temp_dir.path(), None)),
6946                Arc::new(HttpConnector::with_defaults()),
6947                Arc::new(NullSession),
6948                Arc::new(NullUiHandler),
6949                temp_dir.path().to_path_buf(),
6950            );
6951
6952            for request in requests {
6953                dispatcher.dispatch_and_complete(request).await;
6954            }
6955
6956            while runtime.has_pending() {
6957                runtime.tick().await.expect("tick");
6958                runtime.drain_microtasks().await.expect("microtasks");
6959            }
6960
6961            runtime
6962                .eval(
6963                    r#"
6964                    if (globalThis.readResult === null) throw new Error("read not resolved");
6965                    if (globalThis.lsResult === null) throw new Error("ls not resolved");
6966                "#,
6967                )
6968                .await
6969                .expect("verify both tools resolved");
6970        });
6971    }
6972
6973    #[test]
6974    fn dispatcher_tool_error_propagates_to_js() {
6975        futures::executor::block_on(async {
6976            let temp_dir = tempfile::tempdir().expect("tempdir");
6977
6978            let runtime = Rc::new(
6979                PiJsRuntime::with_clock(DeterministicClock::new(0))
6980                    .await
6981                    .expect("runtime"),
6982            );
6983
6984            // Try to read a non-existent file
6985            runtime
6986                .eval(
6987                    r#"
6988                    globalThis.errMsg = "";
6989                    pi.tool("read", { path: "nonexistent_file.txt" })
6990                        .then(() => { globalThis.errMsg = "should_not_resolve"; })
6991                        .catch((e) => { globalThis.errMsg = e.message || String(e); });
6992                "#,
6993                )
6994                .await
6995                .expect("eval");
6996
6997            let requests = runtime.drain_hostcall_requests();
6998            assert_eq!(requests.len(), 1);
6999
7000            let dispatcher = ExtensionDispatcher::new(
7001                Rc::clone(&runtime),
7002                Arc::new(ToolRegistry::new(&["read"], temp_dir.path(), None)),
7003                Arc::new(HttpConnector::with_defaults()),
7004                Arc::new(NullSession),
7005                Arc::new(NullUiHandler),
7006                temp_dir.path().to_path_buf(),
7007            );
7008
7009            for request in requests {
7010                dispatcher.dispatch_and_complete(request).await;
7011            }
7012
7013            while runtime.has_pending() {
7014                runtime.tick().await.expect("tick");
7015                runtime.drain_microtasks().await.expect("microtasks");
7016            }
7017
7018            // The read tool may resolve with an error content rather than rejecting.
7019            // Either way, the dispatcher shouldn't panic.
7020            runtime
7021                .eval(
7022                    r#"
7023                    // Just verify something happened - error propagation is tool-specific
7024                    if (globalThis.errMsg === "" && globalThis.result === null) {
7025                        throw new Error("Neither resolved nor rejected");
7026                    }
7027                "#,
7028                )
7029                .await
7030                .expect("verify tool error handling");
7031        });
7032    }
7033
7034    // ---- HTTP conformance tests ----
7035
7036    fn spawn_http_server_with_status(status: u16, body: &'static str) -> std::net::SocketAddr {
7037        let listener = TcpListener::bind("127.0.0.1:0").expect("bind http server");
7038        let addr = listener.local_addr().expect("server addr");
7039        thread::spawn(move || {
7040            if let Ok((mut stream, _)) = listener.accept() {
7041                let mut buf = [0u8; 1024];
7042                let _ = stream.read(&mut buf);
7043                let response = format!(
7044                    "HTTP/1.1 {status} Error\r\nContent-Length: {len}\r\nContent-Type: text/plain\r\n\r\n{body}",
7045                    status = status,
7046                    len = body.len(),
7047                    body = body,
7048                );
7049                let _ = stream.write_all(response.as_bytes());
7050            }
7051        });
7052        addr
7053    }
7054
7055    #[test]
7056    #[cfg(unix)] // std::net::TcpListener + asupersync interop fails on Windows
7057    fn dispatcher_http_post_sends_body() {
7058        futures::executor::block_on(async {
7059            let addr = spawn_http_server("post-ok");
7060            let url = format!("http://{addr}/data");
7061
7062            let runtime = Rc::new(
7063                PiJsRuntime::with_clock(DeterministicClock::new(0))
7064                    .await
7065                    .expect("runtime"),
7066            );
7067
7068            let script = format!(
7069                r#"
7070                globalThis.result = null;
7071                pi.http({{ url: "{url}", method: "POST", body: "test-payload" }})
7072                    .then((r) => {{ globalThis.result = r; }});
7073            "#
7074            );
7075            runtime.eval(&script).await.expect("eval");
7076
7077            let requests = runtime.drain_hostcall_requests();
7078            assert_eq!(requests.len(), 1);
7079
7080            let http_connector = HttpConnector::new(HttpConnectorConfig {
7081                require_tls: false,
7082                ..Default::default()
7083            });
7084            let dispatcher = ExtensionDispatcher::new(
7085                Rc::clone(&runtime),
7086                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
7087                Arc::new(http_connector),
7088                Arc::new(NullSession),
7089                Arc::new(NullUiHandler),
7090                PathBuf::from("."),
7091            );
7092
7093            for request in requests {
7094                dispatcher.dispatch_and_complete(request).await;
7095            }
7096
7097            while runtime.has_pending() {
7098                runtime.tick().await.expect("tick");
7099                runtime.drain_microtasks().await.expect("microtasks");
7100            }
7101
7102            runtime
7103                .eval(
7104                    r#"
7105                    if (globalThis.result === null) throw new Error("POST not resolved");
7106                    if (globalThis.result.status !== 200) {
7107                        throw new Error("Expected 200, got: " + globalThis.result.status);
7108                    }
7109                "#,
7110                )
7111                .await
7112                .expect("verify POST result");
7113        });
7114    }
7115
7116    #[test]
7117    fn dispatcher_http_missing_url_rejects() {
7118        futures::executor::block_on(async {
7119            let runtime = Rc::new(
7120                PiJsRuntime::with_clock(DeterministicClock::new(0))
7121                    .await
7122                    .expect("runtime"),
7123            );
7124
7125            runtime
7126                .eval(
7127                    r#"
7128                    globalThis.errMsg = "";
7129                    pi.http({ method: "GET" })
7130                        .then(() => { globalThis.errMsg = "should_not_resolve"; })
7131                        .catch((e) => { globalThis.errMsg = e.message || String(e); });
7132                "#,
7133                )
7134                .await
7135                .expect("eval");
7136
7137            let requests = runtime.drain_hostcall_requests();
7138            assert_eq!(requests.len(), 1);
7139
7140            let http_connector = HttpConnector::new(HttpConnectorConfig {
7141                require_tls: false,
7142                ..Default::default()
7143            });
7144            let dispatcher = ExtensionDispatcher::new(
7145                Rc::clone(&runtime),
7146                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
7147                Arc::new(http_connector),
7148                Arc::new(NullSession),
7149                Arc::new(NullUiHandler),
7150                PathBuf::from("."),
7151            );
7152
7153            for request in requests {
7154                dispatcher.dispatch_and_complete(request).await;
7155            }
7156
7157            while runtime.has_pending() {
7158                runtime.tick().await.expect("tick");
7159                runtime.drain_microtasks().await.expect("microtasks");
7160            }
7161
7162            runtime
7163                .eval(
7164                    r#"
7165                    if (globalThis.errMsg === "should_not_resolve") {
7166                        throw new Error("Expected rejection for missing URL");
7167                    }
7168                "#,
7169                )
7170                .await
7171                .expect("verify missing URL rejection");
7172        });
7173    }
7174
7175    #[test]
7176    fn dispatcher_http_custom_headers() {
7177        futures::executor::block_on(async {
7178            let addr = spawn_http_server("headers-ok");
7179            let url = format!("http://{addr}/headers");
7180
7181            let runtime = Rc::new(
7182                PiJsRuntime::with_clock(DeterministicClock::new(0))
7183                    .await
7184                    .expect("runtime"),
7185            );
7186
7187            let script = format!(
7188                r#"
7189                globalThis.result = null;
7190                pi.http({{
7191                    url: "{url}",
7192                    method: "GET",
7193                    headers: {{ "X-Custom": "test-value", "Accept": "application/json" }}
7194                }}).then((r) => {{ globalThis.result = r; }});
7195            "#
7196            );
7197            runtime.eval(&script).await.expect("eval");
7198
7199            let requests = runtime.drain_hostcall_requests();
7200            assert_eq!(requests.len(), 1);
7201
7202            let http_connector = HttpConnector::new(HttpConnectorConfig {
7203                require_tls: false,
7204                ..Default::default()
7205            });
7206            let dispatcher = ExtensionDispatcher::new(
7207                Rc::clone(&runtime),
7208                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
7209                Arc::new(http_connector),
7210                Arc::new(NullSession),
7211                Arc::new(NullUiHandler),
7212                PathBuf::from("."),
7213            );
7214
7215            for request in requests {
7216                dispatcher.dispatch_and_complete(request).await;
7217            }
7218
7219            while runtime.has_pending() {
7220                runtime.tick().await.expect("tick");
7221                runtime.drain_microtasks().await.expect("microtasks");
7222            }
7223
7224            runtime
7225                .eval(
7226                    r#"
7227                    if (globalThis.result === null) throw new Error("HTTP not resolved");
7228                    if (globalThis.result.status !== 200) {
7229                        throw new Error("Expected 200, got: " + globalThis.result.status);
7230                    }
7231                "#,
7232                )
7233                .await
7234                .expect("verify headers request");
7235        });
7236    }
7237
7238    #[test]
7239    fn dispatcher_http_connection_refused_rejects() {
7240        futures::executor::block_on(async {
7241            let runtime = Rc::new(
7242                PiJsRuntime::with_clock(DeterministicClock::new(0))
7243                    .await
7244                    .expect("runtime"),
7245            );
7246
7247            // Use a port that definitely has nothing listening
7248            runtime
7249                .eval(
7250                    r#"
7251                    globalThis.errMsg = "";
7252                    pi.http({ url: "http://127.0.0.1:1/never", method: "GET" })
7253                        .then(() => { globalThis.errMsg = "should_not_resolve"; })
7254                        .catch((e) => { globalThis.errMsg = e.message || String(e); });
7255                "#,
7256                )
7257                .await
7258                .expect("eval");
7259
7260            let requests = runtime.drain_hostcall_requests();
7261            assert_eq!(requests.len(), 1);
7262
7263            let http_connector = HttpConnector::new(HttpConnectorConfig {
7264                require_tls: false,
7265                ..Default::default()
7266            });
7267            let dispatcher = ExtensionDispatcher::new(
7268                Rc::clone(&runtime),
7269                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
7270                Arc::new(http_connector),
7271                Arc::new(NullSession),
7272                Arc::new(NullUiHandler),
7273                PathBuf::from("."),
7274            );
7275
7276            for request in requests {
7277                dispatcher.dispatch_and_complete(request).await;
7278            }
7279
7280            while runtime.has_pending() {
7281                runtime.tick().await.expect("tick");
7282                runtime.drain_microtasks().await.expect("microtasks");
7283            }
7284
7285            runtime
7286                .eval(
7287                    r#"
7288                    if (globalThis.errMsg === "should_not_resolve") {
7289                        throw new Error("Expected rejection for connection refused");
7290                    }
7291                "#,
7292                )
7293                .await
7294                .expect("verify connection refused");
7295        });
7296    }
7297
7298    // ---- UI conformance tests ----
7299
7300    #[test]
7301    fn dispatcher_ui_spinner_method() {
7302        futures::executor::block_on(async {
7303            let runtime = Rc::new(
7304                PiJsRuntime::with_clock(DeterministicClock::new(0))
7305                    .await
7306                    .expect("runtime"),
7307            );
7308
7309            runtime
7310                .eval(
7311                    r#"
7312                    globalThis.result = null;
7313                    pi.ui("spinner", { text: "Loading...", visible: true })
7314                        .then((r) => { globalThis.result = r; });
7315                "#,
7316                )
7317                .await
7318                .expect("eval");
7319
7320            let requests = runtime.drain_hostcall_requests();
7321            assert_eq!(requests.len(), 1);
7322
7323            let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
7324            let ui_handler = Arc::new(TestUiHandler {
7325                captured: Arc::clone(&captured),
7326                response_value: serde_json::json!({ "acknowledged": true }),
7327            });
7328
7329            let dispatcher = ExtensionDispatcher::new(
7330                Rc::clone(&runtime),
7331                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
7332                Arc::new(HttpConnector::with_defaults()),
7333                Arc::new(NullSession),
7334                ui_handler,
7335                PathBuf::from("."),
7336            );
7337
7338            for request in requests {
7339                dispatcher.dispatch_and_complete(request).await;
7340            }
7341
7342            while runtime.has_pending() {
7343                runtime.tick().await.expect("tick");
7344                runtime.drain_microtasks().await.expect("microtasks");
7345            }
7346
7347            let reqs = captured
7348                .lock()
7349                .unwrap_or_else(std::sync::PoisonError::into_inner)
7350                .clone();
7351            assert_eq!(reqs.len(), 1);
7352            assert_eq!(reqs[0].method, "spinner");
7353            assert_eq!(reqs[0].payload["text"], "Loading...");
7354        });
7355    }
7356
7357    #[test]
7358    fn dispatcher_ui_progress_method() {
7359        futures::executor::block_on(async {
7360            let runtime = Rc::new(
7361                PiJsRuntime::with_clock(DeterministicClock::new(0))
7362                    .await
7363                    .expect("runtime"),
7364            );
7365
7366            runtime
7367                .eval(
7368                    r#"
7369                    globalThis.result = null;
7370                    pi.ui("progress", { current: 50, total: 100, label: "Processing" })
7371                        .then((r) => { globalThis.result = r; });
7372                "#,
7373                )
7374                .await
7375                .expect("eval");
7376
7377            let requests = runtime.drain_hostcall_requests();
7378            assert_eq!(requests.len(), 1);
7379
7380            let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
7381            let ui_handler = Arc::new(TestUiHandler {
7382                captured: Arc::clone(&captured),
7383                response_value: Value::Null,
7384            });
7385
7386            let dispatcher = ExtensionDispatcher::new(
7387                Rc::clone(&runtime),
7388                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
7389                Arc::new(HttpConnector::with_defaults()),
7390                Arc::new(NullSession),
7391                ui_handler,
7392                PathBuf::from("."),
7393            );
7394
7395            for request in requests {
7396                dispatcher.dispatch_and_complete(request).await;
7397            }
7398
7399            while runtime.has_pending() {
7400                runtime.tick().await.expect("tick");
7401                runtime.drain_microtasks().await.expect("microtasks");
7402            }
7403
7404            let reqs = captured
7405                .lock()
7406                .unwrap_or_else(std::sync::PoisonError::into_inner)
7407                .clone();
7408            assert_eq!(reqs.len(), 1);
7409            assert_eq!(reqs[0].method, "progress");
7410            assert_eq!(reqs[0].payload["current"], 50);
7411            assert_eq!(reqs[0].payload["total"], 100);
7412        });
7413    }
7414
7415    #[test]
7416    fn dispatcher_ui_notification_method() {
7417        futures::executor::block_on(async {
7418            let runtime = Rc::new(
7419                PiJsRuntime::with_clock(DeterministicClock::new(0))
7420                    .await
7421                    .expect("runtime"),
7422            );
7423
7424            runtime
7425                .eval(
7426                    r#"
7427                    globalThis.result = null;
7428                    pi.ui("notification", { message: "Task complete!", level: "info" })
7429                        .then((r) => { globalThis.result = r; });
7430                "#,
7431                )
7432                .await
7433                .expect("eval");
7434
7435            let requests = runtime.drain_hostcall_requests();
7436            assert_eq!(requests.len(), 1);
7437
7438            let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
7439            let ui_handler = Arc::new(TestUiHandler {
7440                captured: Arc::clone(&captured),
7441                response_value: serde_json::json!({ "shown": true }),
7442            });
7443
7444            let dispatcher = ExtensionDispatcher::new(
7445                Rc::clone(&runtime),
7446                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
7447                Arc::new(HttpConnector::with_defaults()),
7448                Arc::new(NullSession),
7449                ui_handler,
7450                PathBuf::from("."),
7451            );
7452
7453            for request in requests {
7454                dispatcher.dispatch_and_complete(request).await;
7455            }
7456
7457            while runtime.has_pending() {
7458                runtime.tick().await.expect("tick");
7459                runtime.drain_microtasks().await.expect("microtasks");
7460            }
7461
7462            let reqs = captured
7463                .lock()
7464                .unwrap_or_else(std::sync::PoisonError::into_inner)
7465                .clone();
7466            assert_eq!(reqs.len(), 1);
7467            assert_eq!(reqs[0].method, "notification");
7468            assert_eq!(reqs[0].payload["message"], "Task complete!");
7469            assert_eq!(reqs[0].payload["level"], "info");
7470        });
7471    }
7472
7473    #[test]
7474    fn dispatcher_ui_null_handler_returns_null() {
7475        futures::executor::block_on(async {
7476            let runtime = Rc::new(
7477                PiJsRuntime::with_clock(DeterministicClock::new(0))
7478                    .await
7479                    .expect("runtime"),
7480            );
7481
7482            runtime
7483                .eval(
7484                    r#"
7485                    globalThis.result = "__unset__";
7486                    pi.ui("any_method", { key: "value" })
7487                        .then((r) => { globalThis.result = r; });
7488                "#,
7489                )
7490                .await
7491                .expect("eval");
7492
7493            let requests = runtime.drain_hostcall_requests();
7494            assert_eq!(requests.len(), 1);
7495
7496            // Use NullUiHandler - returns None which maps to null
7497            let dispatcher = build_dispatcher(Rc::clone(&runtime));
7498            for request in requests {
7499                dispatcher.dispatch_and_complete(request).await;
7500            }
7501
7502            while runtime.has_pending() {
7503                runtime.tick().await.expect("tick");
7504                runtime.drain_microtasks().await.expect("microtasks");
7505            }
7506
7507            runtime
7508                .eval(
7509                    r#"
7510                    if (globalThis.result === "__unset__") throw new Error("UI not resolved");
7511                    if (globalThis.result !== null) {
7512                        throw new Error("Expected null from NullHandler, got: " + JSON.stringify(globalThis.result));
7513                    }
7514                "#,
7515                )
7516                .await
7517                .expect("verify null UI handler");
7518        });
7519    }
7520
7521    #[test]
7522    fn dispatcher_ui_multiple_calls_captured() {
7523        futures::executor::block_on(async {
7524            let runtime = Rc::new(
7525                PiJsRuntime::with_clock(DeterministicClock::new(0))
7526                    .await
7527                    .expect("runtime"),
7528            );
7529
7530            runtime
7531                .eval(
7532                    r#"
7533                    globalThis.r1 = null;
7534                    globalThis.r2 = null;
7535                    pi.ui("set_status", { text: "Working..." })
7536                        .then((r) => { globalThis.r1 = r; });
7537                    pi.ui("set_widget", { lines: ["Line 1", "Line 2"] })
7538                        .then((r) => { globalThis.r2 = r; });
7539                "#,
7540                )
7541                .await
7542                .expect("eval");
7543
7544            let requests = runtime.drain_hostcall_requests();
7545            assert_eq!(requests.len(), 2);
7546
7547            let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
7548            let ui_handler = Arc::new(TestUiHandler {
7549                captured: Arc::clone(&captured),
7550                response_value: Value::Null,
7551            });
7552
7553            let dispatcher = ExtensionDispatcher::new(
7554                Rc::clone(&runtime),
7555                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
7556                Arc::new(HttpConnector::with_defaults()),
7557                Arc::new(NullSession),
7558                ui_handler,
7559                PathBuf::from("."),
7560            );
7561
7562            for request in requests {
7563                dispatcher.dispatch_and_complete(request).await;
7564            }
7565
7566            while runtime.has_pending() {
7567                runtime.tick().await.expect("tick");
7568                runtime.drain_microtasks().await.expect("microtasks");
7569            }
7570
7571            let (len, methods) = {
7572                let reqs = captured
7573                    .lock()
7574                    .unwrap_or_else(std::sync::PoisonError::into_inner);
7575                let len = reqs.len();
7576                let methods = reqs.iter().map(|r| r.method.clone()).collect::<Vec<_>>();
7577                drop(reqs);
7578                (len, methods)
7579            };
7580            assert_eq!(len, 2);
7581            assert!(methods.iter().any(|method| method == "set_status"));
7582            assert!(methods.iter().any(|method| method == "set_widget"));
7583        });
7584    }
7585
7586    // ---- Exec edge case tests ----
7587
7588    #[test]
7589    fn dispatcher_exec_with_custom_cwd() {
7590        futures::executor::block_on(async {
7591            let runtime = Rc::new(
7592                PiJsRuntime::with_clock(DeterministicClock::new(0))
7593                    .await
7594                    .expect("runtime"),
7595            );
7596
7597            runtime
7598                .eval(
7599                    r#"
7600                    globalThis.result = null;
7601                    pi.exec("pwd", { cwd: "/tmp" })
7602                        .then((r) => { globalThis.result = r; })
7603                        .catch((e) => { globalThis.result = { error: e.message || String(e) }; });
7604                "#,
7605                )
7606                .await
7607                .expect("eval");
7608
7609            let requests = runtime.drain_hostcall_requests();
7610            assert_eq!(requests.len(), 1);
7611
7612            let dispatcher = build_dispatcher(Rc::clone(&runtime));
7613            for request in requests {
7614                dispatcher.dispatch_and_complete(request).await;
7615            }
7616
7617            while runtime.has_pending() {
7618                runtime.tick().await.expect("tick");
7619                runtime.drain_microtasks().await.expect("microtasks");
7620            }
7621
7622            runtime
7623                .eval(
7624                    r#"
7625                    if (!globalThis.result) throw new Error("exec not resolved");
7626                    // Either it resolved to stdout containing /tmp, or it
7627                    // was rejected - both are valid dispatcher behaviors.
7628                    // Key assertion: the dispatcher didn't panic.
7629                "#,
7630                )
7631                .await
7632                .expect("verify exec cwd");
7633        });
7634    }
7635
7636    #[test]
7637    fn dispatcher_exec_empty_command_rejects() {
7638        futures::executor::block_on(async {
7639            let runtime = Rc::new(
7640                PiJsRuntime::with_clock(DeterministicClock::new(0))
7641                    .await
7642                    .expect("runtime"),
7643            );
7644
7645            runtime
7646                .eval(
7647                    r#"
7648                    globalThis.errMsg = "";
7649                    pi.exec("")
7650                        .then(() => { globalThis.errMsg = "should_not_resolve"; })
7651                        .catch((e) => { globalThis.errMsg = e.message || String(e); });
7652                "#,
7653                )
7654                .await
7655                .expect("eval");
7656
7657            let requests = runtime.drain_hostcall_requests();
7658            assert_eq!(requests.len(), 1);
7659
7660            let dispatcher = build_dispatcher(Rc::clone(&runtime));
7661            for request in requests {
7662                dispatcher.dispatch_and_complete(request).await;
7663            }
7664
7665            while runtime.has_pending() {
7666                runtime.tick().await.expect("tick");
7667                runtime.drain_microtasks().await.expect("microtasks");
7668            }
7669
7670            runtime
7671                .eval(
7672                    r#"
7673                    if (globalThis.errMsg === "should_not_resolve") {
7674                        throw new Error("Expected rejection for empty command");
7675                    }
7676                    // Empty command should produce some kind of error
7677                    if (!globalThis.errMsg) {
7678                        throw new Error("Expected error message");
7679                    }
7680                "#,
7681                )
7682                .await
7683                .expect("verify empty command rejection");
7684        });
7685    }
7686
7687    // ---- Events edge case tests ----
7688
7689    #[test]
7690    fn dispatcher_events_emit_missing_event_name_rejects() {
7691        futures::executor::block_on(async {
7692            let runtime = Rc::new(
7693                PiJsRuntime::with_clock(DeterministicClock::new(0))
7694                    .await
7695                    .expect("runtime"),
7696            );
7697
7698            runtime
7699                .eval(
7700                    r#"
7701                    globalThis.errMsg = "";
7702                    pi.events("emit", {})
7703                        .then(() => { globalThis.errMsg = "should_not_resolve"; })
7704                        .catch((e) => { globalThis.errMsg = e.message || String(e); });
7705                "#,
7706                )
7707                .await
7708                .expect("eval");
7709
7710            let requests = runtime.drain_hostcall_requests();
7711            assert_eq!(requests.len(), 1);
7712
7713            let dispatcher = build_dispatcher(Rc::clone(&runtime));
7714            for request in requests {
7715                dispatcher.dispatch_and_complete(request).await;
7716            }
7717
7718            while runtime.has_pending() {
7719                runtime.tick().await.expect("tick");
7720                runtime.drain_microtasks().await.expect("microtasks");
7721            }
7722
7723            runtime
7724                .eval(
7725                    r#"
7726                    // Should either reject or produce an error - not silently succeed
7727                    if (globalThis.errMsg === "should_not_resolve") {
7728                        // It's also acceptable if emit with empty payload succeeds gracefully
7729                    }
7730                "#,
7731                )
7732                .await
7733                .expect("verify events emit");
7734        });
7735    }
7736
7737    #[test]
7738    fn dispatcher_events_list_empty_when_no_hooks() {
7739        futures::executor::block_on(async {
7740            let runtime = Rc::new(
7741                PiJsRuntime::with_clock(DeterministicClock::new(0))
7742                    .await
7743                    .expect("runtime"),
7744            );
7745
7746            // Register an extension with no hooks, then list events
7747            runtime
7748                .eval(
7749                    r#"
7750                    globalThis.result = null;
7751                    __pi_begin_extension("ext.empty", { name: "ext.empty" });
7752                    pi.events("list", {})
7753                        .then((r) => { globalThis.result = r; })
7754                        .catch((e) => { globalThis.result = { error: e.message || String(e) }; });
7755                    __pi_end_extension();
7756                "#,
7757                )
7758                .await
7759                .expect("eval");
7760
7761            let requests = runtime.drain_hostcall_requests();
7762            assert_eq!(requests.len(), 1);
7763
7764            let dispatcher = build_dispatcher(Rc::clone(&runtime));
7765            for request in requests {
7766                dispatcher.dispatch_and_complete(request).await;
7767            }
7768
7769            while runtime.has_pending() {
7770                runtime.tick().await.expect("tick");
7771                runtime.drain_microtasks().await.expect("microtasks");
7772            }
7773
7774            runtime
7775                .eval(
7776                    r#"
7777                    if (!globalThis.result) throw new Error("events list not resolved");
7778                    // Result is { events: [...] }
7779                    const events = globalThis.result.events;
7780                    if (!Array.isArray(events)) {
7781                        throw new Error("Expected events array, got: " + JSON.stringify(globalThis.result));
7782                    }
7783                    if (events.length !== 0) {
7784                        throw new Error("Expected empty events list, got: " + JSON.stringify(events));
7785                    }
7786                "#,
7787                )
7788                .await
7789                .expect("verify events list empty");
7790        });
7791    }
7792
7793    // ---- Isolated session op tests ----
7794
7795    #[test]
7796    fn dispatcher_session_get_file_isolated() {
7797        futures::executor::block_on(async {
7798            let runtime = Rc::new(
7799                PiJsRuntime::with_clock(DeterministicClock::new(0))
7800                    .await
7801                    .expect("runtime"),
7802            );
7803
7804            runtime
7805                .eval(
7806                    r#"
7807                    globalThis.file = "__unset__";
7808                    pi.session("get_file", {})
7809                        .then((r) => { globalThis.file = r; });
7810                "#,
7811                )
7812                .await
7813                .expect("eval");
7814
7815            let requests = runtime.drain_hostcall_requests();
7816            assert_eq!(requests.len(), 1);
7817
7818            let state = Arc::new(Mutex::new(serde_json::json!({
7819                "sessionFile": "/home/user/.pi/sessions/abc.json"
7820            })));
7821            let session = Arc::new(TestSession {
7822                state,
7823                messages: Arc::new(Mutex::new(Vec::new())),
7824                entries: Arc::new(Mutex::new(Vec::new())),
7825                branch: Arc::new(Mutex::new(Vec::new())),
7826                name: Arc::new(Mutex::new(None)),
7827                custom_entries: Arc::new(Mutex::new(Vec::new())),
7828                labels: Arc::new(Mutex::new(Vec::new())),
7829            });
7830
7831            let dispatcher = ExtensionDispatcher::new(
7832                Rc::clone(&runtime),
7833                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
7834                Arc::new(HttpConnector::with_defaults()),
7835                session,
7836                Arc::new(NullUiHandler),
7837                PathBuf::from("."),
7838            );
7839
7840            for request in requests {
7841                dispatcher.dispatch_and_complete(request).await;
7842            }
7843
7844            while runtime.has_pending() {
7845                runtime.tick().await.expect("tick");
7846                runtime.drain_microtasks().await.expect("microtasks");
7847            }
7848
7849            runtime
7850                .eval(
7851                    r#"
7852                    if (globalThis.file === "__unset__") throw new Error("get_file not resolved");
7853                    if (globalThis.file !== "/home/user/.pi/sessions/abc.json") {
7854                        throw new Error("Expected session file path, got: " + JSON.stringify(globalThis.file));
7855                    }
7856                "#,
7857                )
7858                .await
7859                .expect("verify get_file");
7860        });
7861    }
7862
7863    #[test]
7864    fn dispatcher_session_get_name_isolated() {
7865        futures::executor::block_on(async {
7866            let runtime = Rc::new(
7867                PiJsRuntime::with_clock(DeterministicClock::new(0))
7868                    .await
7869                    .expect("runtime"),
7870            );
7871
7872            runtime
7873                .eval(
7874                    r#"
7875                    globalThis.name = "__unset__";
7876                    pi.session("get_name", {})
7877                        .then((r) => { globalThis.name = r; });
7878                "#,
7879                )
7880                .await
7881                .expect("eval");
7882
7883            let requests = runtime.drain_hostcall_requests();
7884            assert_eq!(requests.len(), 1);
7885
7886            let state = Arc::new(Mutex::new(serde_json::json!({
7887                "sessionName": "My Debug Session"
7888            })));
7889            let session = Arc::new(TestSession {
7890                state,
7891                messages: Arc::new(Mutex::new(Vec::new())),
7892                entries: Arc::new(Mutex::new(Vec::new())),
7893                branch: Arc::new(Mutex::new(Vec::new())),
7894                name: Arc::new(Mutex::new(Some("My Debug Session".to_string()))),
7895                custom_entries: Arc::new(Mutex::new(Vec::new())),
7896                labels: Arc::new(Mutex::new(Vec::new())),
7897            });
7898
7899            let dispatcher = ExtensionDispatcher::new(
7900                Rc::clone(&runtime),
7901                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
7902                Arc::new(HttpConnector::with_defaults()),
7903                session,
7904                Arc::new(NullUiHandler),
7905                PathBuf::from("."),
7906            );
7907
7908            for request in requests {
7909                dispatcher.dispatch_and_complete(request).await;
7910            }
7911
7912            while runtime.has_pending() {
7913                runtime.tick().await.expect("tick");
7914                runtime.drain_microtasks().await.expect("microtasks");
7915            }
7916
7917            runtime
7918                .eval(
7919                    r#"
7920                    if (globalThis.name === "__unset__") throw new Error("get_name not resolved");
7921                    if (globalThis.name !== "My Debug Session") {
7922                        throw new Error("Expected session name, got: " + JSON.stringify(globalThis.name));
7923                    }
7924                "#,
7925                )
7926                .await
7927                .expect("verify get_name");
7928        });
7929    }
7930
7931    #[test]
7932    fn dispatcher_session_append_entry_custom_type_edge_cases() {
7933        futures::executor::block_on(async {
7934            let runtime = Rc::new(
7935                PiJsRuntime::with_clock(DeterministicClock::new(0))
7936                    .await
7937                    .expect("runtime"),
7938            );
7939
7940            // Test with custom_type key (snake_case variant)
7941            runtime
7942                .eval(
7943                    r#"
7944                    globalThis.result = "__unset__";
7945                    pi.session("append_entry", {
7946                        custom_type: "audit_log",
7947                        data: { action: "login", ts: 1234567890 }
7948                    }).then((r) => { globalThis.result = r; });
7949                "#,
7950                )
7951                .await
7952                .expect("eval");
7953
7954            let requests = runtime.drain_hostcall_requests();
7955            assert_eq!(requests.len(), 1);
7956
7957            let custom_entries: CustomEntries = Arc::new(Mutex::new(Vec::new()));
7958            let session = Arc::new(TestSession {
7959                state: Arc::new(Mutex::new(serde_json::json!({}))),
7960                messages: Arc::new(Mutex::new(Vec::new())),
7961                entries: Arc::new(Mutex::new(Vec::new())),
7962                branch: Arc::new(Mutex::new(Vec::new())),
7963                name: Arc::new(Mutex::new(None)),
7964                custom_entries: Arc::clone(&custom_entries),
7965                labels: Arc::new(Mutex::new(Vec::new())),
7966            });
7967
7968            let dispatcher = ExtensionDispatcher::new(
7969                Rc::clone(&runtime),
7970                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
7971                Arc::new(HttpConnector::with_defaults()),
7972                session,
7973                Arc::new(NullUiHandler),
7974                PathBuf::from("."),
7975            );
7976
7977            for request in requests {
7978                dispatcher.dispatch_and_complete(request).await;
7979            }
7980
7981            while runtime.has_pending() {
7982                runtime.tick().await.expect("tick");
7983                runtime.drain_microtasks().await.expect("microtasks");
7984            }
7985
7986            let captured = custom_entries
7987                .lock()
7988                .unwrap_or_else(std::sync::PoisonError::into_inner);
7989            assert_eq!(captured.len(), 1);
7990            assert_eq!(captured[0].0, "audit_log");
7991            assert!(captured[0].1.is_some());
7992            let data = captured[0].1.as_ref().unwrap().clone();
7993            drop(captured);
7994            assert_eq!(data["action"], "login");
7995        });
7996    }
7997
7998    #[test]
7999    fn dispatcher_events_emit_dispatches_custom_event() {
8000        futures::executor::block_on(async {
8001            let runtime = Rc::new(
8002                PiJsRuntime::with_clock(DeterministicClock::new(0))
8003                    .await
8004                    .expect("runtime"),
8005            );
8006
8007            runtime
8008                .eval(
8009                    r#"
8010                    globalThis.seen = [];
8011                    globalThis.emitResult = null;
8012
8013                    __pi_begin_extension("ext.b", { name: "ext.b" });
8014                    pi.on("custom_event", (payload, _ctx) => { globalThis.seen.push(payload); });
8015                    __pi_end_extension();
8016
8017                    __pi_begin_extension("ext.a", { name: "ext.a" });
8018                    pi.events("emit", { event: "custom_event", data: { hello: "world" } })
8019                      .then((r) => { globalThis.emitResult = r; });
8020                    __pi_end_extension();
8021                "#,
8022                )
8023                .await
8024                .expect("eval");
8025
8026            let requests = runtime.drain_hostcall_requests();
8027            assert_eq!(requests.len(), 1);
8028
8029            let dispatcher = build_dispatcher(Rc::clone(&runtime));
8030            for request in requests {
8031                dispatcher.dispatch_and_complete(request).await;
8032            }
8033
8034            runtime.tick().await.expect("tick");
8035
8036            runtime
8037                .eval(
8038                    r#"
8039                    if (!globalThis.emitResult) throw new Error("emit promise not resolved");
8040                    if (globalThis.emitResult.dispatched !== true) {
8041                        throw new Error("emit did not report dispatched: " + JSON.stringify(globalThis.emitResult));
8042                    }
8043                    if (globalThis.emitResult.event !== "custom_event") {
8044                        throw new Error("wrong event: " + JSON.stringify(globalThis.emitResult));
8045                    }
8046                    if (!Array.isArray(globalThis.seen) || globalThis.seen.length !== 1) {
8047                        throw new Error("event handler not called: " + JSON.stringify(globalThis.seen));
8048                    }
8049                    const payload = globalThis.seen[0];
8050                    if (!payload || payload.hello !== "world") {
8051                        throw new Error("wrong payload: " + JSON.stringify(payload));
8052                    }
8053                "#,
8054                )
8055                .await
8056                .expect("verify emit");
8057        });
8058    }
8059
8060    // ---- Additional exec conformance tests ----
8061    // These tests use Unix-specific commands (/bin/sh, /bin/echo) and are
8062    // skipped on Windows.
8063
8064    #[test]
8065    #[cfg(unix)]
8066    fn dispatcher_exec_with_args_array() {
8067        futures::executor::block_on(async {
8068            let runtime = Rc::new(
8069                PiJsRuntime::with_clock(DeterministicClock::new(0))
8070                    .await
8071                    .expect("runtime"),
8072            );
8073
8074            // pi.exec(cmd, args, options) - args is the second positional arg
8075            runtime
8076                .eval(
8077                    r#"
8078                    globalThis.result = null;
8079                    pi.exec("/bin/echo", ["hello", "world"], {})
8080                        .then((r) => { globalThis.result = r; })
8081                        .catch((e) => { globalThis.result = { error: e.message || String(e) }; });
8082                "#,
8083                )
8084                .await
8085                .expect("eval");
8086
8087            let requests = runtime.drain_hostcall_requests();
8088            assert_eq!(requests.len(), 1);
8089
8090            let dispatcher = build_dispatcher(Rc::clone(&runtime));
8091            for request in requests {
8092                dispatcher.dispatch_and_complete(request).await;
8093            }
8094
8095            while runtime.has_pending() {
8096                runtime.tick().await.expect("tick");
8097                runtime.drain_microtasks().await.expect("microtasks");
8098            }
8099
8100            runtime
8101                .eval(
8102                    r#"
8103                    if (!globalThis.result) throw new Error("exec not resolved");
8104                    if (globalThis.result.error) throw new Error("exec errored: " + globalThis.result.error);
8105                    if (typeof globalThis.result.stdout !== "string") {
8106                        throw new Error("Expected stdout string, got: " + JSON.stringify(globalThis.result));
8107                    }
8108                    if (!globalThis.result.stdout.includes("hello") || !globalThis.result.stdout.includes("world")) {
8109                        throw new Error("Expected 'hello world' in stdout, got: " + globalThis.result.stdout);
8110                    }
8111                "#,
8112                )
8113                .await
8114                .expect("verify exec with args");
8115        });
8116    }
8117
8118    #[test]
8119    #[cfg(unix)]
8120    fn dispatcher_exec_null_args_defaults_to_empty() {
8121        futures::executor::block_on(async {
8122            let runtime = Rc::new(
8123                PiJsRuntime::with_clock(DeterministicClock::new(0))
8124                    .await
8125                    .expect("runtime"),
8126            );
8127
8128            runtime
8129                .eval(
8130                    r#"
8131                    globalThis.result = null;
8132                    pi.exec("/bin/echo")
8133                        .then((r) => { globalThis.result = r; })
8134                        .catch((e) => { globalThis.result = { error: e.message || String(e) }; });
8135                "#,
8136                )
8137                .await
8138                .expect("eval");
8139
8140            let requests = runtime.drain_hostcall_requests();
8141            assert_eq!(requests.len(), 1);
8142
8143            let dispatcher = build_dispatcher(Rc::clone(&runtime));
8144            for request in requests {
8145                dispatcher.dispatch_and_complete(request).await;
8146            }
8147
8148            while runtime.has_pending() {
8149                runtime.tick().await.expect("tick");
8150                runtime.drain_microtasks().await.expect("microtasks");
8151            }
8152
8153            runtime
8154                .eval(
8155                    r#"
8156                    if (!globalThis.result) throw new Error("exec not resolved");
8157                    // echo with no args produces empty or newline stdout
8158                    if (globalThis.result.error) throw new Error("exec errored: " + globalThis.result.error);
8159                    if (typeof globalThis.result.stdout !== "string") {
8160                        throw new Error("Expected stdout string");
8161                    }
8162                "#,
8163                )
8164                .await
8165                .expect("verify exec null args");
8166        });
8167    }
8168
8169    #[test]
8170    fn dispatcher_exec_non_array_args_rejects() {
8171        futures::executor::block_on(async {
8172            let runtime = Rc::new(
8173                PiJsRuntime::with_clock(DeterministicClock::new(0))
8174                    .await
8175                    .expect("runtime"),
8176            );
8177
8178            runtime
8179                .eval(
8180                    r#"
8181                    globalThis.errMsg = "";
8182                    pi.exec("echo", "not-an-array", {})
8183                        .then(() => { globalThis.errMsg = "should_not_resolve"; })
8184                        .catch((e) => { globalThis.errMsg = e.message || String(e); });
8185                "#,
8186                )
8187                .await
8188                .expect("eval");
8189
8190            let requests = runtime.drain_hostcall_requests();
8191            assert_eq!(requests.len(), 1);
8192
8193            let dispatcher = build_dispatcher(Rc::clone(&runtime));
8194            for request in requests {
8195                dispatcher.dispatch_and_complete(request).await;
8196            }
8197
8198            while runtime.has_pending() {
8199                runtime.tick().await.expect("tick");
8200                runtime.drain_microtasks().await.expect("microtasks");
8201            }
8202
8203            runtime
8204                .eval(
8205                    r#"
8206                    if (globalThis.errMsg === "should_not_resolve") {
8207                        throw new Error("Expected rejection for non-array args");
8208                    }
8209                    if (!globalThis.errMsg.toLowerCase().includes("array")) {
8210                        throw new Error("Expected error about array, got: " + globalThis.errMsg);
8211                    }
8212                "#,
8213                )
8214                .await
8215                .expect("verify non-array args rejection");
8216        });
8217    }
8218
8219    #[test]
8220    #[cfg(unix)]
8221    fn dispatcher_exec_captures_stdout_and_stderr() {
8222        futures::executor::block_on(async {
8223            let runtime = Rc::new(
8224                PiJsRuntime::with_clock(DeterministicClock::new(0))
8225                    .await
8226                    .expect("runtime"),
8227            );
8228
8229            // Use sh -c to write to both stdout and stderr
8230            runtime
8231                .eval(
8232                    r#"
8233                    globalThis.result = null;
8234                    pi.exec("/bin/sh", ["-c", "echo OUT && echo ERR >&2"], {})
8235                        .then((r) => { globalThis.result = r; })
8236                        .catch((e) => { globalThis.result = { error: e.message || String(e) }; });
8237                "#,
8238                )
8239                .await
8240                .expect("eval");
8241
8242            let requests = runtime.drain_hostcall_requests();
8243            assert_eq!(requests.len(), 1);
8244
8245            let dispatcher = build_dispatcher(Rc::clone(&runtime));
8246            for request in requests {
8247                dispatcher.dispatch_and_complete(request).await;
8248            }
8249
8250            while runtime.has_pending() {
8251                runtime.tick().await.expect("tick");
8252                runtime.drain_microtasks().await.expect("microtasks");
8253            }
8254
8255            runtime
8256                .eval(
8257                    r#"
8258                    if (!globalThis.result) throw new Error("exec not resolved");
8259                    if (globalThis.result.error) throw new Error("exec errored: " + globalThis.result.error);
8260                    if (!globalThis.result.stdout.includes("OUT")) {
8261                        throw new Error("Expected 'OUT' in stdout, got: " + globalThis.result.stdout);
8262                    }
8263                    if (!globalThis.result.stderr.includes("ERR")) {
8264                        throw new Error("Expected 'ERR' in stderr, got: " + globalThis.result.stderr);
8265                    }
8266                "#,
8267                )
8268                .await
8269                .expect("verify stdout and stderr capture");
8270        });
8271    }
8272
8273    #[test]
8274    #[cfg(unix)]
8275    fn dispatcher_exec_nonzero_exit_code() {
8276        futures::executor::block_on(async {
8277            let runtime = Rc::new(
8278                PiJsRuntime::with_clock(DeterministicClock::new(0))
8279                    .await
8280                    .expect("runtime"),
8281            );
8282
8283            runtime
8284                .eval(
8285                    r#"
8286                    globalThis.result = null;
8287                    pi.exec("/bin/sh", ["-c", "exit 42"], {})
8288                        .then((r) => { globalThis.result = r; })
8289                        .catch((e) => { globalThis.result = { error: e.message || String(e) }; });
8290                "#,
8291                )
8292                .await
8293                .expect("eval");
8294
8295            let requests = runtime.drain_hostcall_requests();
8296            assert_eq!(requests.len(), 1);
8297
8298            let dispatcher = build_dispatcher(Rc::clone(&runtime));
8299            for request in requests {
8300                dispatcher.dispatch_and_complete(request).await;
8301            }
8302
8303            while runtime.has_pending() {
8304                runtime.tick().await.expect("tick");
8305                runtime.drain_microtasks().await.expect("microtasks");
8306            }
8307
8308            runtime
8309                .eval(
8310                    r#"
8311                    if (!globalThis.result) throw new Error("exec not resolved");
8312                    if (globalThis.result.error) throw new Error("exec errored: " + globalThis.result.error);
8313                    if (globalThis.result.code !== 42) {
8314                        throw new Error("Expected exit code 42, got: " + globalThis.result.code);
8315                    }
8316                "#,
8317                )
8318                .await
8319                .expect("verify nonzero exit code");
8320        });
8321    }
8322
8323    #[cfg(unix)]
8324    #[test]
8325    fn dispatcher_exec_signal_termination_reports_nonzero_code() {
8326        futures::executor::block_on(async {
8327            let runtime = Rc::new(
8328                PiJsRuntime::with_clock(DeterministicClock::new(0))
8329                    .await
8330                    .expect("runtime"),
8331            );
8332
8333            runtime
8334                .eval(
8335                    r#"
8336                    globalThis.result = null;
8337                    pi.exec("/bin/sh", ["-c", "kill -KILL $$"], {})
8338                        .then((r) => { globalThis.result = r; })
8339                        .catch((e) => { globalThis.result = { error: e.message || String(e) }; });
8340                "#,
8341                )
8342                .await
8343                .expect("eval");
8344
8345            let requests = runtime.drain_hostcall_requests();
8346            assert_eq!(requests.len(), 1);
8347
8348            let dispatcher = build_dispatcher(Rc::clone(&runtime));
8349            for request in requests {
8350                dispatcher.dispatch_and_complete(request).await;
8351            }
8352
8353            while runtime.has_pending() {
8354                runtime.tick().await.expect("tick");
8355                runtime.drain_microtasks().await.expect("microtasks");
8356            }
8357
8358            runtime
8359                .eval(
8360                    r#"
8361                    if (!globalThis.result) throw new Error("exec not resolved");
8362                    if (globalThis.result.error) throw new Error("exec errored: " + globalThis.result.error);
8363                    if (globalThis.result.code === 0) {
8364                        throw new Error("Expected non-zero exit code for signal termination, got: " + globalThis.result.code);
8365                    }
8366                "#,
8367                )
8368                .await
8369                .expect("verify signal termination exit code");
8370        });
8371    }
8372
8373    #[test]
8374    fn dispatcher_exec_command_not_found_rejects() {
8375        futures::executor::block_on(async {
8376            let runtime = Rc::new(
8377                PiJsRuntime::with_clock(DeterministicClock::new(0))
8378                    .await
8379                    .expect("runtime"),
8380            );
8381
8382            runtime
8383                .eval(
8384                    r#"
8385                    globalThis.errMsg = "";
8386                    pi.exec("__nonexistent_command_xyz__")
8387                        .then(() => { globalThis.errMsg = "should_not_resolve"; })
8388                        .catch((e) => { globalThis.errMsg = e.message || String(e); });
8389                "#,
8390                )
8391                .await
8392                .expect("eval");
8393
8394            let requests = runtime.drain_hostcall_requests();
8395            assert_eq!(requests.len(), 1);
8396
8397            let dispatcher = build_dispatcher(Rc::clone(&runtime));
8398            for request in requests {
8399                dispatcher.dispatch_and_complete(request).await;
8400            }
8401
8402            while runtime.has_pending() {
8403                runtime.tick().await.expect("tick");
8404                runtime.drain_microtasks().await.expect("microtasks");
8405            }
8406
8407            runtime
8408                .eval(
8409                    r#"
8410                    if (globalThis.errMsg === "should_not_resolve") {
8411                        throw new Error("Expected rejection for nonexistent command");
8412                    }
8413                    if (!globalThis.errMsg) {
8414                        throw new Error("Expected error message for nonexistent command");
8415                    }
8416                "#,
8417                )
8418                .await
8419                .expect("verify command not found rejection");
8420        });
8421    }
8422
8423    // ---- Additional HTTP conformance tests ----
8424
8425    #[test]
8426    fn dispatcher_http_tls_required_rejects_http_url() {
8427        futures::executor::block_on(async {
8428            let runtime = Rc::new(
8429                PiJsRuntime::with_clock(DeterministicClock::new(0))
8430                    .await
8431                    .expect("runtime"),
8432            );
8433
8434            runtime
8435                .eval(
8436                    r#"
8437                    globalThis.errMsg = "";
8438                    pi.http({ url: "http://example.com/test", method: "GET" })
8439                        .then(() => { globalThis.errMsg = "should_not_resolve"; })
8440                        .catch((e) => { globalThis.errMsg = e.message || String(e); });
8441                "#,
8442                )
8443                .await
8444                .expect("eval");
8445
8446            let requests = runtime.drain_hostcall_requests();
8447            assert_eq!(requests.len(), 1);
8448
8449            // Use default config which has require_tls: true
8450            let dispatcher = build_dispatcher(Rc::clone(&runtime));
8451            for request in requests {
8452                dispatcher.dispatch_and_complete(request).await;
8453            }
8454
8455            while runtime.has_pending() {
8456                runtime.tick().await.expect("tick");
8457                runtime.drain_microtasks().await.expect("microtasks");
8458            }
8459
8460            runtime
8461                .eval(
8462                    r#"
8463                    if (globalThis.errMsg === "should_not_resolve") {
8464                        throw new Error("Expected rejection for http:// URL when TLS required");
8465                    }
8466                    if (!globalThis.errMsg.toLowerCase().includes("tls") &&
8467                        !globalThis.errMsg.toLowerCase().includes("https")) {
8468                        throw new Error("Expected TLS-related error, got: " + globalThis.errMsg);
8469                    }
8470                "#,
8471                )
8472                .await
8473                .expect("verify TLS enforcement");
8474        });
8475    }
8476
8477    #[test]
8478    fn dispatcher_http_invalid_url_format_rejects() {
8479        futures::executor::block_on(async {
8480            let runtime = Rc::new(
8481                PiJsRuntime::with_clock(DeterministicClock::new(0))
8482                    .await
8483                    .expect("runtime"),
8484            );
8485
8486            runtime
8487                .eval(
8488                    r#"
8489                    globalThis.errMsg = "";
8490                    pi.http({ url: "not-a-valid-url", method: "GET" })
8491                        .then(() => { globalThis.errMsg = "should_not_resolve"; })
8492                        .catch((e) => { globalThis.errMsg = e.message || String(e); });
8493                "#,
8494                )
8495                .await
8496                .expect("eval");
8497
8498            let requests = runtime.drain_hostcall_requests();
8499            assert_eq!(requests.len(), 1);
8500
8501            let http_connector = HttpConnector::new(HttpConnectorConfig {
8502                require_tls: false,
8503                ..Default::default()
8504            });
8505            let dispatcher = ExtensionDispatcher::new(
8506                Rc::clone(&runtime),
8507                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
8508                Arc::new(http_connector),
8509                Arc::new(NullSession),
8510                Arc::new(NullUiHandler),
8511                PathBuf::from("."),
8512            );
8513
8514            for request in requests {
8515                dispatcher.dispatch_and_complete(request).await;
8516            }
8517
8518            while runtime.has_pending() {
8519                runtime.tick().await.expect("tick");
8520                runtime.drain_microtasks().await.expect("microtasks");
8521            }
8522
8523            runtime
8524                .eval(
8525                    r#"
8526                    if (globalThis.errMsg === "should_not_resolve") {
8527                        throw new Error("Expected rejection for invalid URL");
8528                    }
8529                    if (!globalThis.errMsg) {
8530                        throw new Error("Expected error message for invalid URL");
8531                    }
8532                "#,
8533                )
8534                .await
8535                .expect("verify invalid URL rejection");
8536        });
8537    }
8538
8539    #[test]
8540    fn dispatcher_http_get_with_body_rejects() {
8541        futures::executor::block_on(async {
8542            let runtime = Rc::new(
8543                PiJsRuntime::with_clock(DeterministicClock::new(0))
8544                    .await
8545                    .expect("runtime"),
8546            );
8547
8548            runtime
8549                .eval(
8550                    r#"
8551                    globalThis.errMsg = "";
8552                    pi.http({ url: "https://example.com/test", method: "GET", body: "should-not-have-body" })
8553                        .then(() => { globalThis.errMsg = "should_not_resolve"; })
8554                        .catch((e) => { globalThis.errMsg = e.message || String(e); });
8555                "#,
8556                )
8557                .await
8558                .expect("eval");
8559
8560            let requests = runtime.drain_hostcall_requests();
8561            assert_eq!(requests.len(), 1);
8562
8563            let dispatcher = build_dispatcher(Rc::clone(&runtime));
8564            for request in requests {
8565                dispatcher.dispatch_and_complete(request).await;
8566            }
8567
8568            while runtime.has_pending() {
8569                runtime.tick().await.expect("tick");
8570                runtime.drain_microtasks().await.expect("microtasks");
8571            }
8572
8573            runtime
8574                .eval(
8575                    r#"
8576                    if (globalThis.errMsg === "should_not_resolve") {
8577                        throw new Error("Expected rejection for GET with body");
8578                    }
8579                    if (!globalThis.errMsg.toLowerCase().includes("body") &&
8580                        !globalThis.errMsg.toLowerCase().includes("get")) {
8581                        throw new Error("Expected body/GET error, got: " + globalThis.errMsg);
8582                    }
8583                "#,
8584                )
8585                .await
8586                .expect("verify GET with body rejection");
8587        });
8588    }
8589
8590    #[test]
8591    fn dispatcher_http_response_body_returned() {
8592        futures::executor::block_on(async {
8593            let addr = spawn_http_server_with_status(200, "response-body-content");
8594            let url = format!("http://{addr}/body-test");
8595
8596            let runtime = Rc::new(
8597                PiJsRuntime::with_clock(DeterministicClock::new(0))
8598                    .await
8599                    .expect("runtime"),
8600            );
8601
8602            let script = format!(
8603                r#"
8604                globalThis.result = null;
8605                pi.http({{ url: "{url}", method: "GET" }})
8606                    .then((r) => {{ globalThis.result = r; }})
8607                    .catch((e) => {{ globalThis.result = {{ error: e.message || String(e) }}; }});
8608            "#
8609            );
8610            runtime.eval(&script).await.expect("eval");
8611
8612            let requests = runtime.drain_hostcall_requests();
8613            assert_eq!(requests.len(), 1);
8614
8615            let http_connector = HttpConnector::new(HttpConnectorConfig {
8616                require_tls: false,
8617                ..Default::default()
8618            });
8619            let dispatcher = ExtensionDispatcher::new(
8620                Rc::clone(&runtime),
8621                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
8622                Arc::new(http_connector),
8623                Arc::new(NullSession),
8624                Arc::new(NullUiHandler),
8625                PathBuf::from("."),
8626            );
8627
8628            for request in requests {
8629                dispatcher.dispatch_and_complete(request).await;
8630            }
8631
8632            while runtime.has_pending() {
8633                runtime.tick().await.expect("tick");
8634                runtime.drain_microtasks().await.expect("microtasks");
8635            }
8636
8637            runtime
8638                .eval(
8639                    r#"
8640                    if (!globalThis.result) throw new Error("HTTP not resolved");
8641                    if (globalThis.result.error) throw new Error("HTTP error: " + globalThis.result.error);
8642                    if (globalThis.result.status !== 200) {
8643                        throw new Error("Expected 200, got: " + globalThis.result.status);
8644                    }
8645                    const body = globalThis.result.body || "";
8646                    if (!body.includes("response-body-content")) {
8647                        throw new Error("Expected response body, got: " + body);
8648                    }
8649                "#,
8650                )
8651                .await
8652                .expect("verify response body");
8653        });
8654    }
8655
8656    #[test]
8657    fn dispatcher_http_error_status_code_returned() {
8658        futures::executor::block_on(async {
8659            let addr = spawn_http_server_with_status(404, "not found");
8660            let url = format!("http://{addr}/missing");
8661
8662            let runtime = Rc::new(
8663                PiJsRuntime::with_clock(DeterministicClock::new(0))
8664                    .await
8665                    .expect("runtime"),
8666            );
8667
8668            let script = format!(
8669                r#"
8670                globalThis.result = null;
8671                pi.http({{ url: "{url}", method: "GET" }})
8672                    .then((r) => {{ globalThis.result = r; }})
8673                    .catch((e) => {{ globalThis.result = {{ error: e.message || String(e) }}; }});
8674            "#
8675            );
8676            runtime.eval(&script).await.expect("eval");
8677
8678            let requests = runtime.drain_hostcall_requests();
8679            assert_eq!(requests.len(), 1);
8680
8681            let http_connector = HttpConnector::new(HttpConnectorConfig {
8682                require_tls: false,
8683                ..Default::default()
8684            });
8685            let dispatcher = ExtensionDispatcher::new(
8686                Rc::clone(&runtime),
8687                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
8688                Arc::new(http_connector),
8689                Arc::new(NullSession),
8690                Arc::new(NullUiHandler),
8691                PathBuf::from("."),
8692            );
8693
8694            for request in requests {
8695                dispatcher.dispatch_and_complete(request).await;
8696            }
8697
8698            while runtime.has_pending() {
8699                runtime.tick().await.expect("tick");
8700                runtime.drain_microtasks().await.expect("microtasks");
8701            }
8702
8703            runtime
8704                .eval(
8705                    r#"
8706                    if (!globalThis.result) throw new Error("HTTP not resolved");
8707                    // 404 should still resolve (not reject) with the status code
8708                    if (globalThis.result.status !== 404) {
8709                        throw new Error("Expected status 404, got: " + JSON.stringify(globalThis.result));
8710                    }
8711                "#,
8712                )
8713                .await
8714                .expect("verify error status code");
8715        });
8716    }
8717
8718    #[test]
8719    fn dispatcher_http_unsupported_scheme_rejects() {
8720        futures::executor::block_on(async {
8721            let runtime = Rc::new(
8722                PiJsRuntime::with_clock(DeterministicClock::new(0))
8723                    .await
8724                    .expect("runtime"),
8725            );
8726
8727            runtime
8728                .eval(
8729                    r#"
8730                    globalThis.errMsg = "";
8731                    pi.http({ url: "ftp://example.com/file", method: "GET" })
8732                        .then(() => { globalThis.errMsg = "should_not_resolve"; })
8733                        .catch((e) => { globalThis.errMsg = e.message || String(e); });
8734                "#,
8735                )
8736                .await
8737                .expect("eval");
8738
8739            let requests = runtime.drain_hostcall_requests();
8740            assert_eq!(requests.len(), 1);
8741
8742            let http_connector = HttpConnector::new(HttpConnectorConfig {
8743                require_tls: false,
8744                ..Default::default()
8745            });
8746            let dispatcher = ExtensionDispatcher::new(
8747                Rc::clone(&runtime),
8748                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
8749                Arc::new(http_connector),
8750                Arc::new(NullSession),
8751                Arc::new(NullUiHandler),
8752                PathBuf::from("."),
8753            );
8754
8755            for request in requests {
8756                dispatcher.dispatch_and_complete(request).await;
8757            }
8758
8759            while runtime.has_pending() {
8760                runtime.tick().await.expect("tick");
8761                runtime.drain_microtasks().await.expect("microtasks");
8762            }
8763
8764            runtime
8765                .eval(
8766                    r#"
8767                    if (globalThis.errMsg === "should_not_resolve") {
8768                        throw new Error("Expected rejection for ftp:// scheme");
8769                    }
8770                    if (!globalThis.errMsg.toLowerCase().includes("scheme") &&
8771                        !globalThis.errMsg.toLowerCase().includes("unsupported")) {
8772                        throw new Error("Expected scheme error, got: " + globalThis.errMsg);
8773                    }
8774                "#,
8775                )
8776                .await
8777                .expect("verify unsupported scheme rejection");
8778        });
8779    }
8780
8781    // ---- Additional UI conformance tests ----
8782
8783    #[test]
8784    fn dispatcher_ui_arbitrary_method_passthrough() {
8785        futures::executor::block_on(async {
8786            let runtime = Rc::new(
8787                PiJsRuntime::with_clock(DeterministicClock::new(0))
8788                    .await
8789                    .expect("runtime"),
8790            );
8791
8792            runtime
8793                .eval(
8794                    r#"
8795                    globalThis.result = null;
8796                    pi.ui("custom_op", { key: "value" })
8797                        .then((r) => { globalThis.result = r; });
8798                "#,
8799                )
8800                .await
8801                .expect("eval");
8802
8803            let requests = runtime.drain_hostcall_requests();
8804            assert_eq!(requests.len(), 1);
8805
8806            let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
8807            let ui_handler = Arc::new(TestUiHandler {
8808                captured: Arc::clone(&captured),
8809                response_value: Value::Null,
8810            });
8811
8812            let dispatcher = ExtensionDispatcher::new(
8813                Rc::clone(&runtime),
8814                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
8815                Arc::new(HttpConnector::with_defaults()),
8816                Arc::new(NullSession),
8817                ui_handler,
8818                PathBuf::from("."),
8819            );
8820
8821            for request in requests {
8822                dispatcher.dispatch_and_complete(request).await;
8823            }
8824
8825            while runtime.has_pending() {
8826                runtime.tick().await.expect("tick");
8827                runtime.drain_microtasks().await.expect("microtasks");
8828            }
8829
8830            let reqs = captured
8831                .lock()
8832                .unwrap_or_else(std::sync::PoisonError::into_inner)
8833                .clone();
8834            assert_eq!(reqs.len(), 1);
8835            assert_eq!(reqs[0].method, "custom_op");
8836            assert_eq!(reqs[0].payload["key"], "value");
8837        });
8838    }
8839
8840    #[test]
8841    fn dispatcher_ui_payload_passthrough_complex() {
8842        futures::executor::block_on(async {
8843            let runtime = Rc::new(
8844                PiJsRuntime::with_clock(DeterministicClock::new(0))
8845                    .await
8846                    .expect("runtime"),
8847            );
8848
8849            runtime
8850                .eval(
8851                    r#"
8852                    globalThis.result = null;
8853                    pi.ui("set_widget", {
8854                        lines: [
8855                            { text: "Line 1", style: { bold: true } },
8856                            { text: "Line 2", style: { color: "red" } }
8857                        ],
8858                        content: "widget body",
8859                        metadata: { nested: { deep: true } }
8860                    }).then((r) => { globalThis.result = r; });
8861                "#,
8862                )
8863                .await
8864                .expect("eval");
8865
8866            let requests = runtime.drain_hostcall_requests();
8867            assert_eq!(requests.len(), 1);
8868
8869            let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
8870            let ui_handler = Arc::new(TestUiHandler {
8871                captured: Arc::clone(&captured),
8872                response_value: Value::Null,
8873            });
8874
8875            let dispatcher = ExtensionDispatcher::new(
8876                Rc::clone(&runtime),
8877                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
8878                Arc::new(HttpConnector::with_defaults()),
8879                Arc::new(NullSession),
8880                ui_handler,
8881                PathBuf::from("."),
8882            );
8883
8884            for request in requests {
8885                dispatcher.dispatch_and_complete(request).await;
8886            }
8887
8888            while runtime.has_pending() {
8889                runtime.tick().await.expect("tick");
8890                runtime.drain_microtasks().await.expect("microtasks");
8891            }
8892
8893            let reqs = captured
8894                .lock()
8895                .unwrap_or_else(std::sync::PoisonError::into_inner)
8896                .clone();
8897            assert_eq!(reqs.len(), 1);
8898            let payload = &reqs[0].payload;
8899            assert!(payload["lines"].is_array());
8900            assert_eq!(payload["lines"].as_array().unwrap().len(), 2);
8901            assert_eq!(payload["content"], "widget body");
8902            assert_eq!(payload["metadata"]["nested"]["deep"], true);
8903        });
8904    }
8905
8906    #[test]
8907    fn dispatcher_ui_handler_returns_value() {
8908        futures::executor::block_on(async {
8909            let runtime = Rc::new(
8910                PiJsRuntime::with_clock(DeterministicClock::new(0))
8911                    .await
8912                    .expect("runtime"),
8913            );
8914
8915            runtime
8916                .eval(
8917                    r#"
8918                    globalThis.result = "__unset__";
8919                    pi.ui("get_input", { prompt: "Enter name" })
8920                        .then((r) => { globalThis.result = r; });
8921                "#,
8922                )
8923                .await
8924                .expect("eval");
8925
8926            let requests = runtime.drain_hostcall_requests();
8927            assert_eq!(requests.len(), 1);
8928
8929            let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
8930            let ui_handler = Arc::new(TestUiHandler {
8931                captured: Arc::clone(&captured),
8932                response_value: serde_json::json!({ "input": "Alice", "confirmed": true }),
8933            });
8934
8935            let dispatcher = ExtensionDispatcher::new(
8936                Rc::clone(&runtime),
8937                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
8938                Arc::new(HttpConnector::with_defaults()),
8939                Arc::new(NullSession),
8940                ui_handler,
8941                PathBuf::from("."),
8942            );
8943
8944            for request in requests {
8945                dispatcher.dispatch_and_complete(request).await;
8946            }
8947
8948            while runtime.has_pending() {
8949                runtime.tick().await.expect("tick");
8950                runtime.drain_microtasks().await.expect("microtasks");
8951            }
8952
8953            runtime
8954                .eval(
8955                    r#"
8956                    if (globalThis.result === "__unset__") throw new Error("UI not resolved");
8957                    if (globalThis.result.input !== "Alice") {
8958                        throw new Error("Expected input 'Alice', got: " + JSON.stringify(globalThis.result));
8959                    }
8960                    if (globalThis.result.confirmed !== true) {
8961                        throw new Error("Expected confirmed true");
8962                    }
8963                "#,
8964                )
8965                .await
8966                .expect("verify UI handler value");
8967        });
8968    }
8969
8970    #[test]
8971    fn dispatcher_ui_set_status_empty_text() {
8972        futures::executor::block_on(async {
8973            let runtime = Rc::new(
8974                PiJsRuntime::with_clock(DeterministicClock::new(0))
8975                    .await
8976                    .expect("runtime"),
8977            );
8978
8979            runtime
8980                .eval(
8981                    r#"
8982                    globalThis.result = null;
8983                    pi.ui("set_status", { text: "" })
8984                        .then((r) => { globalThis.result = r; });
8985                "#,
8986                )
8987                .await
8988                .expect("eval");
8989
8990            let requests = runtime.drain_hostcall_requests();
8991            assert_eq!(requests.len(), 1);
8992
8993            let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
8994            let ui_handler = Arc::new(TestUiHandler {
8995                captured: Arc::clone(&captured),
8996                response_value: Value::Null,
8997            });
8998
8999            let dispatcher = ExtensionDispatcher::new(
9000                Rc::clone(&runtime),
9001                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
9002                Arc::new(HttpConnector::with_defaults()),
9003                Arc::new(NullSession),
9004                ui_handler,
9005                PathBuf::from("."),
9006            );
9007
9008            for request in requests {
9009                dispatcher.dispatch_and_complete(request).await;
9010            }
9011
9012            while runtime.has_pending() {
9013                runtime.tick().await.expect("tick");
9014                runtime.drain_microtasks().await.expect("microtasks");
9015            }
9016
9017            let reqs = captured
9018                .lock()
9019                .unwrap_or_else(std::sync::PoisonError::into_inner)
9020                .clone();
9021            assert_eq!(reqs.len(), 1);
9022            assert_eq!(reqs[0].method, "set_status");
9023            assert_eq!(reqs[0].payload["text"], "");
9024        });
9025    }
9026
9027    #[test]
9028    fn dispatcher_ui_empty_payload() {
9029        futures::executor::block_on(async {
9030            let runtime = Rc::new(
9031                PiJsRuntime::with_clock(DeterministicClock::new(0))
9032                    .await
9033                    .expect("runtime"),
9034            );
9035
9036            runtime
9037                .eval(
9038                    r#"
9039                    globalThis.result = null;
9040                    pi.ui("dismiss", {})
9041                        .then((r) => { globalThis.result = r; });
9042                "#,
9043                )
9044                .await
9045                .expect("eval");
9046
9047            let requests = runtime.drain_hostcall_requests();
9048            assert_eq!(requests.len(), 1);
9049
9050            let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
9051            let ui_handler = Arc::new(TestUiHandler {
9052                captured: Arc::clone(&captured),
9053                response_value: Value::Null,
9054            });
9055
9056            let dispatcher = ExtensionDispatcher::new(
9057                Rc::clone(&runtime),
9058                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
9059                Arc::new(HttpConnector::with_defaults()),
9060                Arc::new(NullSession),
9061                ui_handler,
9062                PathBuf::from("."),
9063            );
9064
9065            for request in requests {
9066                dispatcher.dispatch_and_complete(request).await;
9067            }
9068
9069            while runtime.has_pending() {
9070                runtime.tick().await.expect("tick");
9071                runtime.drain_microtasks().await.expect("microtasks");
9072            }
9073
9074            let reqs = captured
9075                .lock()
9076                .unwrap_or_else(std::sync::PoisonError::into_inner)
9077                .clone();
9078            assert_eq!(reqs.len(), 1);
9079            assert_eq!(reqs[0].method, "dismiss");
9080        });
9081    }
9082
9083    #[test]
9084    fn dispatcher_ui_concurrent_different_methods() {
9085        futures::executor::block_on(async {
9086            let runtime = Rc::new(
9087                PiJsRuntime::with_clock(DeterministicClock::new(0))
9088                    .await
9089                    .expect("runtime"),
9090            );
9091
9092            runtime
9093                .eval(
9094                    r#"
9095                    globalThis.results = [];
9096                    pi.ui("set_status", { text: "Loading..." })
9097                        .then((r) => { globalThis.results.push("status"); });
9098                    pi.ui("show_spinner", { message: "Working" })
9099                        .then((r) => { globalThis.results.push("spinner"); });
9100                    pi.ui("set_widget", { lines: [], content: "w" })
9101                        .then((r) => { globalThis.results.push("widget"); });
9102                "#,
9103                )
9104                .await
9105                .expect("eval");
9106
9107            let requests = runtime.drain_hostcall_requests();
9108            assert_eq!(requests.len(), 3);
9109
9110            let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
9111            let ui_handler = Arc::new(TestUiHandler {
9112                captured: Arc::clone(&captured),
9113                response_value: Value::Null,
9114            });
9115
9116            let dispatcher = ExtensionDispatcher::new(
9117                Rc::clone(&runtime),
9118                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
9119                Arc::new(HttpConnector::with_defaults()),
9120                Arc::new(NullSession),
9121                ui_handler,
9122                PathBuf::from("."),
9123            );
9124
9125            for request in requests {
9126                dispatcher.dispatch_and_complete(request).await;
9127            }
9128
9129            while runtime.has_pending() {
9130                runtime.tick().await.expect("tick");
9131                runtime.drain_microtasks().await.expect("microtasks");
9132            }
9133
9134            let reqs = captured
9135                .lock()
9136                .unwrap_or_else(std::sync::PoisonError::into_inner)
9137                .clone();
9138            assert_eq!(reqs.len(), 3);
9139            let methods: Vec<&str> = reqs.iter().map(|r| r.method.as_str()).collect();
9140            assert!(methods.contains(&"set_status"));
9141            assert!(methods.contains(&"show_spinner"));
9142            assert!(methods.contains(&"set_widget"));
9143        });
9144    }
9145
9146    #[test]
9147    fn dispatcher_ui_notification_with_severity() {
9148        futures::executor::block_on(async {
9149            let runtime = Rc::new(
9150                PiJsRuntime::with_clock(DeterministicClock::new(0))
9151                    .await
9152                    .expect("runtime"),
9153            );
9154
9155            runtime
9156                .eval(
9157                    r#"
9158                    globalThis.result = null;
9159                    pi.ui("notification", { text: "Error occurred", severity: "error", duration: 5000 })
9160                        .then((r) => { globalThis.result = r; });
9161                "#,
9162                )
9163                .await
9164                .expect("eval");
9165
9166            let requests = runtime.drain_hostcall_requests();
9167            assert_eq!(requests.len(), 1);
9168
9169            let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
9170            let ui_handler = Arc::new(TestUiHandler {
9171                captured: Arc::clone(&captured),
9172                response_value: Value::Null,
9173            });
9174
9175            let dispatcher = ExtensionDispatcher::new(
9176                Rc::clone(&runtime),
9177                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
9178                Arc::new(HttpConnector::with_defaults()),
9179                Arc::new(NullSession),
9180                ui_handler,
9181                PathBuf::from("."),
9182            );
9183
9184            for request in requests {
9185                dispatcher.dispatch_and_complete(request).await;
9186            }
9187
9188            while runtime.has_pending() {
9189                runtime.tick().await.expect("tick");
9190                runtime.drain_microtasks().await.expect("microtasks");
9191            }
9192
9193            let reqs = captured
9194                .lock()
9195                .unwrap_or_else(std::sync::PoisonError::into_inner)
9196                .clone();
9197            assert_eq!(reqs.len(), 1);
9198            assert_eq!(reqs[0].method, "notification");
9199            assert_eq!(reqs[0].payload["severity"], "error");
9200            assert_eq!(reqs[0].payload["duration"], 5000);
9201        });
9202    }
9203
9204    #[test]
9205    fn dispatcher_ui_widget_with_lines_array() {
9206        futures::executor::block_on(async {
9207            let runtime = Rc::new(
9208                PiJsRuntime::with_clock(DeterministicClock::new(0))
9209                    .await
9210                    .expect("runtime"),
9211            );
9212
9213            runtime
9214                .eval(
9215                    r#"
9216                    globalThis.result = null;
9217                    pi.ui("set_widget", {
9218                        lines: [
9219                            { text: "=== Status ===" },
9220                            { text: "CPU: 42%" },
9221                            { text: "Mem: 8GB" }
9222                        ],
9223                        content: "Dashboard"
9224                    }).then((r) => { globalThis.result = r; });
9225                "#,
9226                )
9227                .await
9228                .expect("eval");
9229
9230            let requests = runtime.drain_hostcall_requests();
9231            assert_eq!(requests.len(), 1);
9232
9233            let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
9234            let ui_handler = Arc::new(TestUiHandler {
9235                captured: Arc::clone(&captured),
9236                response_value: Value::Null,
9237            });
9238
9239            let dispatcher = ExtensionDispatcher::new(
9240                Rc::clone(&runtime),
9241                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
9242                Arc::new(HttpConnector::with_defaults()),
9243                Arc::new(NullSession),
9244                ui_handler,
9245                PathBuf::from("."),
9246            );
9247
9248            for request in requests {
9249                dispatcher.dispatch_and_complete(request).await;
9250            }
9251
9252            while runtime.has_pending() {
9253                runtime.tick().await.expect("tick");
9254                runtime.drain_microtasks().await.expect("microtasks");
9255            }
9256
9257            let reqs = captured
9258                .lock()
9259                .unwrap_or_else(std::sync::PoisonError::into_inner)
9260                .clone();
9261            assert_eq!(reqs.len(), 1);
9262            assert_eq!(reqs[0].method, "set_widget");
9263            let lines = reqs[0].payload["lines"].as_array().unwrap();
9264            assert_eq!(lines.len(), 3);
9265            assert_eq!(lines[0]["text"], "=== Status ===");
9266            assert_eq!(lines[2]["text"], "Mem: 8GB");
9267        });
9268    }
9269
9270    #[test]
9271    fn dispatcher_ui_progress_with_percentage() {
9272        futures::executor::block_on(async {
9273            let runtime = Rc::new(
9274                PiJsRuntime::with_clock(DeterministicClock::new(0))
9275                    .await
9276                    .expect("runtime"),
9277            );
9278
9279            runtime
9280                .eval(
9281                    r#"
9282                    globalThis.result = null;
9283                    pi.ui("progress", { message: "Uploading", percent: 75, total: 100, current: 75 })
9284                        .then((r) => { globalThis.result = r; });
9285                "#,
9286                )
9287                .await
9288                .expect("eval");
9289
9290            let requests = runtime.drain_hostcall_requests();
9291            assert_eq!(requests.len(), 1);
9292
9293            let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
9294            let ui_handler = Arc::new(TestUiHandler {
9295                captured: Arc::clone(&captured),
9296                response_value: Value::Null,
9297            });
9298
9299            let dispatcher = ExtensionDispatcher::new(
9300                Rc::clone(&runtime),
9301                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
9302                Arc::new(HttpConnector::with_defaults()),
9303                Arc::new(NullSession),
9304                ui_handler,
9305                PathBuf::from("."),
9306            );
9307
9308            for request in requests {
9309                dispatcher.dispatch_and_complete(request).await;
9310            }
9311
9312            while runtime.has_pending() {
9313                runtime.tick().await.expect("tick");
9314                runtime.drain_microtasks().await.expect("microtasks");
9315            }
9316
9317            let reqs = captured
9318                .lock()
9319                .unwrap_or_else(std::sync::PoisonError::into_inner)
9320                .clone();
9321            assert_eq!(reqs.len(), 1);
9322            assert_eq!(reqs[0].method, "progress");
9323            assert_eq!(reqs[0].payload["percent"], 75);
9324            assert_eq!(reqs[0].payload["total"], 100);
9325            assert_eq!(reqs[0].payload["current"], 75);
9326        });
9327    }
9328
9329    // ---- Additional events conformance tests ----
9330
9331    #[test]
9332    fn dispatcher_events_emit_name_field_alias() {
9333        futures::executor::block_on(async {
9334            let runtime = Rc::new(
9335                PiJsRuntime::with_clock(DeterministicClock::new(0))
9336                    .await
9337                    .expect("runtime"),
9338            );
9339
9340            // Use "name" instead of "event" field
9341            runtime
9342                .eval(
9343                    r#"
9344                    globalThis.seen = [];
9345                    globalThis.emitResult = null;
9346
9347                    __pi_begin_extension("ext.listener", { name: "ext.listener" });
9348                    pi.on("named_event", (payload, _ctx) => { globalThis.seen.push(payload); });
9349                    __pi_end_extension();
9350
9351                    __pi_begin_extension("ext.emitter", { name: "ext.emitter" });
9352                    pi.events("emit", { name: "named_event", data: { via: "name_field" } })
9353                      .then((r) => { globalThis.emitResult = r; });
9354                    __pi_end_extension();
9355                "#,
9356                )
9357                .await
9358                .expect("eval");
9359
9360            let requests = runtime.drain_hostcall_requests();
9361            assert_eq!(requests.len(), 1);
9362
9363            let dispatcher = build_dispatcher(Rc::clone(&runtime));
9364            for request in requests {
9365                dispatcher.dispatch_and_complete(request).await;
9366            }
9367
9368            runtime.tick().await.expect("tick");
9369
9370            runtime
9371                .eval(
9372                    r#"
9373                    if (!globalThis.emitResult) throw new Error("emit not resolved");
9374                    if (globalThis.emitResult.dispatched !== true) {
9375                        throw new Error("emit not dispatched: " + JSON.stringify(globalThis.emitResult));
9376                    }
9377                    if (globalThis.seen.length !== 1) {
9378                        throw new Error("Expected 1 handler call, got: " + globalThis.seen.length);
9379                    }
9380                    if (globalThis.seen[0].via !== "name_field") {
9381                        throw new Error("Wrong payload: " + JSON.stringify(globalThis.seen[0]));
9382                    }
9383                "#,
9384                )
9385                .await
9386                .expect("verify name field alias");
9387        });
9388    }
9389
9390    #[test]
9391    fn dispatcher_events_unsupported_op_rejects() {
9392        futures::executor::block_on(async {
9393            let runtime = Rc::new(
9394                PiJsRuntime::with_clock(DeterministicClock::new(0))
9395                    .await
9396                    .expect("runtime"),
9397            );
9398
9399            runtime
9400                .eval(
9401                    r#"
9402                    globalThis.errMsg = "";
9403                    pi.events("nonexistent_op", {})
9404                        .then(() => { globalThis.errMsg = "should_not_resolve"; })
9405                        .catch((e) => { globalThis.errMsg = e.message || String(e); });
9406                "#,
9407                )
9408                .await
9409                .expect("eval");
9410
9411            let requests = runtime.drain_hostcall_requests();
9412            assert_eq!(requests.len(), 1);
9413
9414            let dispatcher = build_dispatcher(Rc::clone(&runtime));
9415            for request in requests {
9416                dispatcher.dispatch_and_complete(request).await;
9417            }
9418
9419            while runtime.has_pending() {
9420                runtime.tick().await.expect("tick");
9421                runtime.drain_microtasks().await.expect("microtasks");
9422            }
9423
9424            runtime
9425                .eval(
9426                    r#"
9427                    if (globalThis.errMsg === "should_not_resolve") {
9428                        throw new Error("Expected rejection for unsupported events op");
9429                    }
9430                    if (!globalThis.errMsg.toLowerCase().includes("unsupported")) {
9431                        throw new Error("Expected 'unsupported' error, got: " + globalThis.errMsg);
9432                    }
9433                "#,
9434                )
9435                .await
9436                .expect("verify unsupported op rejection");
9437        });
9438    }
9439
9440    #[test]
9441    fn dispatcher_events_emit_empty_event_name_rejects() {
9442        futures::executor::block_on(async {
9443            let runtime = Rc::new(
9444                PiJsRuntime::with_clock(DeterministicClock::new(0))
9445                    .await
9446                    .expect("runtime"),
9447            );
9448
9449            runtime
9450                .eval(
9451                    r#"
9452                    globalThis.errMsg = "";
9453                    pi.events("emit", { event: "" })
9454                        .then(() => { globalThis.errMsg = "should_not_resolve"; })
9455                        .catch((e) => { globalThis.errMsg = e.message || String(e); });
9456                "#,
9457                )
9458                .await
9459                .expect("eval");
9460
9461            let requests = runtime.drain_hostcall_requests();
9462            assert_eq!(requests.len(), 1);
9463
9464            let dispatcher = build_dispatcher(Rc::clone(&runtime));
9465            for request in requests {
9466                dispatcher.dispatch_and_complete(request).await;
9467            }
9468
9469            while runtime.has_pending() {
9470                runtime.tick().await.expect("tick");
9471                runtime.drain_microtasks().await.expect("microtasks");
9472            }
9473
9474            runtime
9475                .eval(
9476                    r#"
9477                    if (globalThis.errMsg === "should_not_resolve") {
9478                        throw new Error("Expected rejection for empty event name");
9479                    }
9480                    if (!globalThis.errMsg.includes("event") && !globalThis.errMsg.includes("non-empty")) {
9481                        throw new Error("Expected event name error, got: " + globalThis.errMsg);
9482                    }
9483                "#,
9484                )
9485                .await
9486                .expect("verify empty event name rejection");
9487        });
9488    }
9489
9490    #[test]
9491    fn dispatcher_events_emit_handler_count_in_response() {
9492        futures::executor::block_on(async {
9493            let runtime = Rc::new(
9494                PiJsRuntime::with_clock(DeterministicClock::new(0))
9495                    .await
9496                    .expect("runtime"),
9497            );
9498
9499            // Register 2 handlers for same event
9500            runtime
9501                .eval(
9502                    r#"
9503                    globalThis.emitResult = null;
9504
9505                    __pi_begin_extension("ext.h1", { name: "ext.h1" });
9506                    pi.on("counted_event", (_p, _c) => {});
9507                    __pi_end_extension();
9508
9509                    __pi_begin_extension("ext.h2", { name: "ext.h2" });
9510                    pi.on("counted_event", (_p, _c) => {});
9511                    __pi_end_extension();
9512
9513                    __pi_begin_extension("ext.emitter", { name: "ext.emitter" });
9514                    pi.events("emit", { event: "counted_event", data: {} })
9515                      .then((r) => { globalThis.emitResult = r; });
9516                    __pi_end_extension();
9517                "#,
9518                )
9519                .await
9520                .expect("eval");
9521
9522            let requests = runtime.drain_hostcall_requests();
9523            assert_eq!(requests.len(), 1);
9524
9525            let dispatcher = build_dispatcher(Rc::clone(&runtime));
9526            for request in requests {
9527                dispatcher.dispatch_and_complete(request).await;
9528            }
9529
9530            runtime.tick().await.expect("tick");
9531
9532            runtime
9533                .eval(
9534                    r#"
9535                    if (!globalThis.emitResult) throw new Error("emit not resolved");
9536                    if (globalThis.emitResult.dispatched !== true) {
9537                        throw new Error("emit not dispatched: " + JSON.stringify(globalThis.emitResult));
9538                    }
9539                    if (typeof globalThis.emitResult.handler_count !== "number") {
9540                        throw new Error("Expected handler_count number, got: " + JSON.stringify(globalThis.emitResult));
9541                    }
9542                    if (globalThis.emitResult.handler_count < 2) {
9543                        throw new Error("Expected at least 2 handlers, got: " + globalThis.emitResult.handler_count);
9544                    }
9545                "#,
9546                )
9547                .await
9548                .expect("verify handler count");
9549        });
9550    }
9551
9552    #[test]
9553    fn dispatcher_events_list_returns_registered_event_names() {
9554        futures::executor::block_on(async {
9555            let runtime = Rc::new(
9556                PiJsRuntime::with_clock(DeterministicClock::new(0))
9557                    .await
9558                    .expect("runtime"),
9559            );
9560
9561            // Register multiple event hooks
9562            runtime
9563                .eval(
9564                    r#"
9565                    globalThis.result = null;
9566
9567                    __pi_begin_extension("ext.multi", { name: "ext.multi" });
9568                    pi.on("event_alpha", (_p, _c) => {});
9569                    pi.on("event_beta", (_p, _c) => {});
9570                    pi.on("event_gamma", (_p, _c) => {});
9571                    pi.events("list", {})
9572                        .then((r) => { globalThis.result = r; })
9573                        .catch((e) => { globalThis.result = { error: e.message || String(e) }; });
9574                    __pi_end_extension();
9575                "#,
9576                )
9577                .await
9578                .expect("eval");
9579
9580            let requests = runtime.drain_hostcall_requests();
9581            assert_eq!(requests.len(), 1);
9582
9583            let dispatcher = build_dispatcher(Rc::clone(&runtime));
9584            for request in requests {
9585                dispatcher.dispatch_and_complete(request).await;
9586            }
9587
9588            while runtime.has_pending() {
9589                runtime.tick().await.expect("tick");
9590                runtime.drain_microtasks().await.expect("microtasks");
9591            }
9592
9593            runtime
9594                .eval(
9595                    r#"
9596                    if (!globalThis.result) throw new Error("list not resolved");
9597                    if (globalThis.result.error) throw new Error("list error: " + globalThis.result.error);
9598                    const events = globalThis.result.events;
9599                    if (!Array.isArray(events)) {
9600                        throw new Error("Expected events array, got: " + JSON.stringify(globalThis.result));
9601                    }
9602                    if (events.length < 3) {
9603                        throw new Error("Expected at least 3 events, got: " + JSON.stringify(events));
9604                    }
9605                    if (!events.includes("event_alpha")) {
9606                        throw new Error("Missing event_alpha in: " + JSON.stringify(events));
9607                    }
9608                    if (!events.includes("event_beta")) {
9609                        throw new Error("Missing event_beta in: " + JSON.stringify(events));
9610                    }
9611                "#,
9612                )
9613                .await
9614                .expect("verify event names list");
9615        });
9616    }
9617
9618    #[test]
9619    fn dispatcher_events_emit_no_handlers_still_resolves() {
9620        futures::executor::block_on(async {
9621            let runtime = Rc::new(
9622                PiJsRuntime::with_clock(DeterministicClock::new(0))
9623                    .await
9624                    .expect("runtime"),
9625            );
9626
9627            // Emit an event that has no registered handlers
9628            runtime
9629                .eval(
9630                    r#"
9631                    globalThis.emitResult = null;
9632
9633                    __pi_begin_extension("ext.lonely", { name: "ext.lonely" });
9634                    pi.events("emit", { event: "unheard_event", data: { msg: "nobody listens" } })
9635                      .then((r) => { globalThis.emitResult = r; })
9636                      .catch((e) => { globalThis.emitResult = { error: e.message || String(e) }; });
9637                    __pi_end_extension();
9638                "#,
9639                )
9640                .await
9641                .expect("eval");
9642
9643            let requests = runtime.drain_hostcall_requests();
9644            assert_eq!(requests.len(), 1);
9645
9646            let dispatcher = build_dispatcher(Rc::clone(&runtime));
9647            for request in requests {
9648                dispatcher.dispatch_and_complete(request).await;
9649            }
9650
9651            while runtime.has_pending() {
9652                runtime.tick().await.expect("tick");
9653                runtime.drain_microtasks().await.expect("microtasks");
9654            }
9655
9656            runtime
9657                .eval(
9658                    r#"
9659                    if (!globalThis.emitResult) throw new Error("emit not resolved");
9660                    // Should resolve even with no handlers (dispatched: true, handler_count: 0)
9661                    if (globalThis.emitResult.error) {
9662                        throw new Error("emit errored: " + globalThis.emitResult.error);
9663                    }
9664                    if (globalThis.emitResult.dispatched !== true) {
9665                        throw new Error("emit not dispatched: " + JSON.stringify(globalThis.emitResult));
9666                    }
9667                "#,
9668                )
9669                .await
9670                .expect("verify emit with no handlers");
9671        });
9672    }
9673
9674    // ---- Additional tool conformance tests ----
9675
9676    #[test]
9677    fn dispatcher_tool_read_returns_file_content() {
9678        futures::executor::block_on(async {
9679            let temp_dir = tempfile::tempdir().expect("tempdir");
9680            let file_path = temp_dir.path().join("readable.txt");
9681            std::fs::write(&file_path, "file content here").expect("write test file");
9682
9683            let runtime = Rc::new(
9684                PiJsRuntime::with_clock(DeterministicClock::new(0))
9685                    .await
9686                    .expect("runtime"),
9687            );
9688
9689            let file_path_js = file_path.display().to_string().replace('\\', "\\\\");
9690            let script = format!(
9691                r#"
9692                globalThis.result = null;
9693                pi.tool("read", {{ path: "{file_path_js}" }})
9694                    .then((r) => {{ globalThis.result = r; }})
9695                    .catch((e) => {{ globalThis.result = {{ error: e.message || String(e) }}; }});
9696            "#
9697            );
9698            runtime.eval(&script).await.expect("eval");
9699
9700            let requests = runtime.drain_hostcall_requests();
9701            assert_eq!(requests.len(), 1);
9702
9703            let dispatcher = ExtensionDispatcher::new(
9704                Rc::clone(&runtime),
9705                Arc::new(ToolRegistry::new(&["read"], temp_dir.path(), None)),
9706                Arc::new(HttpConnector::with_defaults()),
9707                Arc::new(NullSession),
9708                Arc::new(NullUiHandler),
9709                temp_dir.path().to_path_buf(),
9710            );
9711
9712            for request in requests {
9713                dispatcher.dispatch_and_complete(request).await;
9714            }
9715
9716            while runtime.has_pending() {
9717                runtime.tick().await.expect("tick");
9718                runtime.drain_microtasks().await.expect("microtasks");
9719            }
9720
9721            runtime
9722                .eval(
9723                    r#"
9724                    if (!globalThis.result) throw new Error("read not resolved");
9725                    if (globalThis.result.error) throw new Error("read error: " + globalThis.result.error);
9726                "#,
9727                )
9728                .await
9729                .expect("verify read tool");
9730        });
9731    }
9732
9733    // ======================================================================
9734    // bd-321a.4: Session dispatcher taxonomy tests
9735    // ======================================================================
9736    // Table-driven tests proving dispatch_session returns taxonomy-correct
9737    // error codes (timeout|denied|io|invalid_request|internal).
9738
9739    /// Direct unit test of dispatch_session error taxonomy without JS runtime.
9740    /// Uses TestSession to verify error code classification for each operation.
9741    #[test]
9742    fn session_dispatch_taxonomy_unknown_op_is_invalid_request() {
9743        futures::executor::block_on(async {
9744            let runtime = Rc::new(
9745                PiJsRuntime::with_clock(DeterministicClock::new(0))
9746                    .await
9747                    .expect("runtime"),
9748            );
9749            let dispatcher = build_dispatcher(Rc::clone(&runtime));
9750            let outcome = dispatcher
9751                .dispatch_session("c1", "nonexistent_op", serde_json::json!({}))
9752                .await;
9753            match outcome {
9754                HostcallOutcome::Error { code, .. } => {
9755                    assert_eq!(
9756                        code, "invalid_request",
9757                        "unknown op must be invalid_request"
9758                    );
9759                }
9760                HostcallOutcome::Success(_) | HostcallOutcome::StreamChunk { .. } => {
9761                    panic!();
9762                }
9763            }
9764        });
9765    }
9766
9767    #[test]
9768    fn session_dispatch_taxonomy_set_model_missing_provider_is_invalid_request() {
9769        futures::executor::block_on(async {
9770            let runtime = Rc::new(
9771                PiJsRuntime::with_clock(DeterministicClock::new(0))
9772                    .await
9773                    .expect("runtime"),
9774            );
9775            let dispatcher = build_dispatcher(Rc::clone(&runtime));
9776            let outcome = dispatcher
9777                .dispatch_session("c2", "set_model", serde_json::json!({"modelId": "gpt-4o"}))
9778                .await;
9779            match outcome {
9780                HostcallOutcome::Error { code, .. } => {
9781                    assert_eq!(
9782                        code, "invalid_request",
9783                        "set_model missing provider must be invalid_request"
9784                    );
9785                }
9786                HostcallOutcome::Success(_) => {
9787                    panic!();
9788                }
9789                HostcallOutcome::StreamChunk { .. } => {
9790                    panic!();
9791                }
9792            }
9793        });
9794    }
9795
9796    #[test]
9797    fn session_dispatch_taxonomy_set_model_missing_model_id_is_invalid_request() {
9798        futures::executor::block_on(async {
9799            let runtime = Rc::new(
9800                PiJsRuntime::with_clock(DeterministicClock::new(0))
9801                    .await
9802                    .expect("runtime"),
9803            );
9804            let dispatcher = build_dispatcher(Rc::clone(&runtime));
9805            let outcome = dispatcher
9806                .dispatch_session(
9807                    "c3",
9808                    "set_model",
9809                    serde_json::json!({"provider": "anthropic"}),
9810                )
9811                .await;
9812            match outcome {
9813                HostcallOutcome::Error { code, .. } => {
9814                    assert_eq!(code, "invalid_request");
9815                }
9816                HostcallOutcome::Success(_) => {
9817                    panic!();
9818                }
9819                HostcallOutcome::StreamChunk { .. } => {
9820                    panic!();
9821                }
9822            }
9823        });
9824    }
9825
9826    #[test]
9827    fn session_dispatch_taxonomy_set_thinking_level_empty_is_invalid_request() {
9828        futures::executor::block_on(async {
9829            let runtime = Rc::new(
9830                PiJsRuntime::with_clock(DeterministicClock::new(0))
9831                    .await
9832                    .expect("runtime"),
9833            );
9834            let dispatcher = build_dispatcher(Rc::clone(&runtime));
9835            let outcome = dispatcher
9836                .dispatch_session("c4", "set_thinking_level", serde_json::json!({}))
9837                .await;
9838            match outcome {
9839                HostcallOutcome::Error { code, .. } => {
9840                    assert_eq!(code, "invalid_request");
9841                }
9842                HostcallOutcome::Success(_) => {
9843                    panic!();
9844                }
9845                HostcallOutcome::StreamChunk { .. } => {
9846                    panic!();
9847                }
9848            }
9849        });
9850    }
9851
9852    #[test]
9853    fn session_dispatch_taxonomy_set_label_empty_target_is_invalid_request() {
9854        futures::executor::block_on(async {
9855            let runtime = Rc::new(
9856                PiJsRuntime::with_clock(DeterministicClock::new(0))
9857                    .await
9858                    .expect("runtime"),
9859            );
9860            let dispatcher = build_dispatcher(Rc::clone(&runtime));
9861            let outcome = dispatcher
9862                .dispatch_session("c5", "set_label", serde_json::json!({}))
9863                .await;
9864            match outcome {
9865                HostcallOutcome::Error { code, .. } => {
9866                    assert_eq!(code, "invalid_request");
9867                }
9868                HostcallOutcome::Success(_) => {
9869                    panic!();
9870                }
9871                HostcallOutcome::StreamChunk { .. } => {
9872                    panic!();
9873                }
9874            }
9875        });
9876    }
9877
9878    #[test]
9879    fn session_dispatch_taxonomy_append_message_invalid_is_invalid_request() {
9880        futures::executor::block_on(async {
9881            let runtime = Rc::new(
9882                PiJsRuntime::with_clock(DeterministicClock::new(0))
9883                    .await
9884                    .expect("runtime"),
9885            );
9886            let dispatcher = build_dispatcher(Rc::clone(&runtime));
9887            let outcome = dispatcher
9888                .dispatch_session(
9889                    "c6",
9890                    "append_message",
9891                    serde_json::json!({"message": {"not_a_valid_message": true}}),
9892                )
9893                .await;
9894            match outcome {
9895                HostcallOutcome::Error { code, .. } => {
9896                    assert_eq!(
9897                        code, "invalid_request",
9898                        "malformed message must be invalid_request"
9899                    );
9900                }
9901                HostcallOutcome::Success(_) => {
9902                    panic!();
9903                }
9904                HostcallOutcome::StreamChunk { .. } => {
9905                    panic!();
9906                }
9907            }
9908        });
9909    }
9910
9911    #[test]
9912    #[allow(clippy::items_after_statements, clippy::too_many_lines)]
9913    fn session_dispatch_taxonomy_io_error_from_session_trait() {
9914        futures::executor::block_on(async {
9915            let runtime = Rc::new(
9916                PiJsRuntime::with_clock(DeterministicClock::new(0))
9917                    .await
9918                    .expect("runtime"),
9919            );
9920
9921            // Use a session impl that returns IO errors
9922            struct FailSession;
9923
9924            #[async_trait]
9925            impl ExtensionSession for FailSession {
9926                async fn get_state(&self) -> Value {
9927                    Value::Null
9928                }
9929                async fn get_messages(&self) -> Vec<SessionMessage> {
9930                    Vec::new()
9931                }
9932                async fn get_entries(&self) -> Vec<Value> {
9933                    Vec::new()
9934                }
9935                async fn get_branch(&self) -> Vec<Value> {
9936                    Vec::new()
9937                }
9938                async fn set_name(&self, _name: String) -> Result<()> {
9939                    Err(crate::error::Error::from(std::io::Error::other(
9940                        "disk full",
9941                    )))
9942                }
9943                async fn append_message(&self, _message: SessionMessage) -> Result<()> {
9944                    Err(crate::error::Error::from(std::io::Error::other(
9945                        "disk full",
9946                    )))
9947                }
9948                async fn append_custom_entry(
9949                    &self,
9950                    _custom_type: String,
9951                    _data: Option<Value>,
9952                ) -> Result<()> {
9953                    Err(crate::error::Error::from(std::io::Error::other(
9954                        "disk full",
9955                    )))
9956                }
9957                async fn set_model(&self, _provider: String, _model_id: String) -> Result<()> {
9958                    Err(crate::error::Error::from(std::io::Error::other(
9959                        "disk full",
9960                    )))
9961                }
9962                async fn get_model(&self) -> (Option<String>, Option<String>) {
9963                    (None, None)
9964                }
9965                async fn set_thinking_level(&self, _level: String) -> Result<()> {
9966                    Err(crate::error::Error::from(std::io::Error::other(
9967                        "disk full",
9968                    )))
9969                }
9970                async fn get_thinking_level(&self) -> Option<String> {
9971                    None
9972                }
9973                async fn set_label(
9974                    &self,
9975                    _target_id: String,
9976                    _label: Option<String>,
9977                ) -> Result<()> {
9978                    Err(crate::error::Error::from(std::io::Error::other(
9979                        "disk full",
9980                    )))
9981                }
9982            }
9983
9984            let dispatcher = ExtensionDispatcher::new(
9985                Rc::clone(&runtime),
9986                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
9987                Arc::new(HttpConnector::with_defaults()),
9988                Arc::new(FailSession),
9989                Arc::new(NullUiHandler),
9990                PathBuf::from("."),
9991            );
9992
9993            // Table of ops that call session trait mutators (which will fail with IO error)
9994            let io_cases = [
9995                ("set_name", serde_json::json!({"name": "test"})),
9996                (
9997                    "set_model",
9998                    serde_json::json!({"provider": "a", "modelId": "b"}),
9999                ),
10000                ("set_thinking_level", serde_json::json!({"level": "high"})),
10001                (
10002                    "set_label",
10003                    serde_json::json!({"targetId": "abc", "label": "x"}),
10004                ),
10005                (
10006                    "append_entry",
10007                    serde_json::json!({"customType": "note", "data": null}),
10008                ),
10009                (
10010                    "append_message",
10011                    serde_json::json!({"message": {"role": "custom", "customType": "x", "content": "y", "display": true}}),
10012                ),
10013            ];
10014
10015            for (op, params) in &io_cases {
10016                let outcome = dispatcher.dispatch_session("cx", op, params.clone()).await;
10017                match outcome {
10018                    HostcallOutcome::Error { code, .. } => {
10019                        assert_eq!(code, "io", "session IO error for op '{op}' must be 'io'");
10020                    }
10021                    HostcallOutcome::Success(_) => {
10022                        panic!();
10023                    }
10024                    HostcallOutcome::StreamChunk { .. } => {
10025                        panic!();
10026                    }
10027                }
10028            }
10029        });
10030    }
10031
10032    #[test]
10033    fn session_dispatch_taxonomy_read_ops_succeed_with_null_session() {
10034        futures::executor::block_on(async {
10035            let runtime = Rc::new(
10036                PiJsRuntime::with_clock(DeterministicClock::new(0))
10037                    .await
10038                    .expect("runtime"),
10039            );
10040            let dispatcher = build_dispatcher(Rc::clone(&runtime));
10041
10042            let read_ops = [
10043                "get_state",
10044                "getState",
10045                "get_messages",
10046                "getMessages",
10047                "get_entries",
10048                "getEntries",
10049                "get_branch",
10050                "getBranch",
10051                "get_file",
10052                "getFile",
10053                "get_name",
10054                "getName",
10055                "get_model",
10056                "getModel",
10057                "get_thinking_level",
10058                "getThinkingLevel",
10059            ];
10060
10061            for op in &read_ops {
10062                let outcome = dispatcher
10063                    .dispatch_session("cr", op, serde_json::json!({}))
10064                    .await;
10065                assert!(
10066                    matches!(outcome, HostcallOutcome::Success(_)),
10067                    "read op '{op}' should succeed"
10068                );
10069            }
10070        });
10071    }
10072
10073    #[test]
10074    fn session_dispatch_taxonomy_case_insensitive_aliases() {
10075        futures::executor::block_on(async {
10076            let runtime = Rc::new(
10077                PiJsRuntime::with_clock(DeterministicClock::new(0))
10078                    .await
10079                    .expect("runtime"),
10080            );
10081            let dispatcher = build_dispatcher(Rc::clone(&runtime));
10082
10083            // Each alias pair should produce the same result
10084            let alias_pairs = [
10085                ("get_state", "getstate"),
10086                ("get_messages", "getmessages"),
10087                ("get_entries", "getentries"),
10088                ("get_branch", "getbranch"),
10089                ("get_file", "getfile"),
10090                ("get_name", "getname"),
10091                ("get_model", "getmodel"),
10092                ("get_thinking_level", "getthinkinglevel"),
10093            ];
10094
10095            for (snake, camel) in &alias_pairs {
10096                let outcome_a = dispatcher
10097                    .dispatch_session("ca", snake, serde_json::json!({}))
10098                    .await;
10099                let outcome_b = dispatcher
10100                    .dispatch_session("cb", camel, serde_json::json!({}))
10101                    .await;
10102                match (&outcome_a, &outcome_b) {
10103                    (HostcallOutcome::Success(a), HostcallOutcome::Success(b)) => {
10104                        assert_eq!(
10105                            a, b,
10106                            "alias pair ({snake}, {camel}) should produce same output"
10107                        );
10108                    }
10109                    _ => panic!(),
10110                }
10111            }
10112        });
10113    }
10114
10115    #[test]
10116    fn ui_dispatch_taxonomy_missing_op_is_invalid_request() {
10117        futures::executor::block_on(async {
10118            let runtime = Rc::new(
10119                PiJsRuntime::with_clock(DeterministicClock::new(0))
10120                    .await
10121                    .expect("runtime"),
10122            );
10123            let dispatcher = build_dispatcher(Rc::clone(&runtime));
10124            let outcome = dispatcher
10125                .dispatch_ui("ui-1", "   ", serde_json::json!({}), None)
10126                .await;
10127            assert!(
10128                matches!(outcome, HostcallOutcome::Error { code, .. } if code == "invalid_request")
10129            );
10130        });
10131    }
10132
10133    #[test]
10134    fn ui_dispatch_taxonomy_timeout_error_maps_to_timeout() {
10135        futures::executor::block_on(async {
10136            struct TimeoutUiHandler;
10137
10138            #[async_trait]
10139            impl ExtensionUiHandler for TimeoutUiHandler {
10140                async fn request_ui(
10141                    &self,
10142                    _request: ExtensionUiRequest,
10143                ) -> Result<Option<ExtensionUiResponse>> {
10144                    Err(Error::extension("Extension UI request timed out"))
10145                }
10146            }
10147
10148            let runtime = Rc::new(
10149                PiJsRuntime::with_clock(DeterministicClock::new(0))
10150                    .await
10151                    .expect("runtime"),
10152            );
10153            let dispatcher = ExtensionDispatcher::new(
10154                Rc::clone(&runtime),
10155                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
10156                Arc::new(HttpConnector::with_defaults()),
10157                Arc::new(NullSession),
10158                Arc::new(TimeoutUiHandler),
10159                PathBuf::from("."),
10160            );
10161
10162            let outcome = dispatcher
10163                .dispatch_ui("ui-2", "confirm", serde_json::json!({}), None)
10164                .await;
10165            assert!(matches!(outcome, HostcallOutcome::Error { code, .. } if code == "timeout"));
10166        });
10167    }
10168
10169    #[test]
10170    fn ui_dispatch_taxonomy_unconfigured_maps_to_denied() {
10171        futures::executor::block_on(async {
10172            struct MissingUiHandler;
10173
10174            #[async_trait]
10175            impl ExtensionUiHandler for MissingUiHandler {
10176                async fn request_ui(
10177                    &self,
10178                    _request: ExtensionUiRequest,
10179                ) -> Result<Option<ExtensionUiResponse>> {
10180                    Err(Error::extension("Extension UI sender not configured"))
10181                }
10182            }
10183
10184            let runtime = Rc::new(
10185                PiJsRuntime::with_clock(DeterministicClock::new(0))
10186                    .await
10187                    .expect("runtime"),
10188            );
10189            let dispatcher = ExtensionDispatcher::new(
10190                Rc::clone(&runtime),
10191                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
10192                Arc::new(HttpConnector::with_defaults()),
10193                Arc::new(NullSession),
10194                Arc::new(MissingUiHandler),
10195                PathBuf::from("."),
10196            );
10197
10198            let outcome = dispatcher
10199                .dispatch_ui("ui-3", "confirm", serde_json::json!({}), None)
10200                .await;
10201            assert!(matches!(outcome, HostcallOutcome::Error { code, .. } if code == "denied"));
10202        });
10203    }
10204
10205    #[test]
10206    fn protocol_adapter_host_call_to_host_result_success() {
10207        futures::executor::block_on(async {
10208            let runtime = Rc::new(
10209                PiJsRuntime::with_clock(DeterministicClock::new(0))
10210                    .await
10211                    .expect("runtime"),
10212            );
10213            let dispatcher = build_dispatcher(Rc::clone(&runtime));
10214            let message = ExtensionMessage {
10215                id: "msg-hostcall-1".to_string(),
10216                version: PROTOCOL_VERSION.to_string(),
10217                body: ExtensionBody::HostCall(HostCallPayload {
10218                    call_id: "call-hostcall-1".to_string(),
10219                    capability: "session".to_string(),
10220                    method: "session".to_string(),
10221                    params: serde_json::json!({ "op": "get_state" }),
10222                    timeout_ms: None,
10223                    cancel_token: None,
10224                    context: None,
10225                }),
10226            };
10227
10228            let response = dispatcher
10229                .dispatch_protocol_message(message)
10230                .await
10231                .expect("protocol dispatch");
10232
10233            match response.body {
10234                ExtensionBody::HostResult(result) => {
10235                    assert_eq!(result.call_id, "call-hostcall-1");
10236                    assert!(!result.is_error, "expected success host_result");
10237                    assert!(
10238                        result.output.is_object(),
10239                        "host_result output must remain object"
10240                    );
10241                    assert!(result.error.is_none(), "success should not include error");
10242                }
10243                other => panic!(),
10244            }
10245        });
10246    }
10247
10248    #[test]
10249    fn protocol_adapter_missing_op_returns_invalid_request_taxonomy() {
10250        futures::executor::block_on(async {
10251            let runtime = Rc::new(
10252                PiJsRuntime::with_clock(DeterministicClock::new(0))
10253                    .await
10254                    .expect("runtime"),
10255            );
10256            let dispatcher = build_dispatcher(Rc::clone(&runtime));
10257            let message = ExtensionMessage {
10258                id: "msg-hostcall-2".to_string(),
10259                version: PROTOCOL_VERSION.to_string(),
10260                body: ExtensionBody::HostCall(HostCallPayload {
10261                    call_id: "call-hostcall-2".to_string(),
10262                    capability: "session".to_string(),
10263                    method: "session".to_string(),
10264                    params: serde_json::json!({}),
10265                    timeout_ms: None,
10266                    cancel_token: None,
10267                    context: None,
10268                }),
10269            };
10270
10271            let response = dispatcher
10272                .dispatch_protocol_message(message)
10273                .await
10274                .expect("protocol dispatch");
10275
10276            match response.body {
10277                ExtensionBody::HostResult(result) => {
10278                    assert!(result.is_error, "expected error host_result");
10279                    assert!(result.output.is_object(), "error output must be object");
10280                    let error = result.error.expect("error payload");
10281                    assert_eq!(
10282                        error.code,
10283                        crate::extensions::HostCallErrorCode::InvalidRequest
10284                    );
10285                    let details = error.details.expect("error details");
10286                    assert_eq!(
10287                        details["dispatcherDecisionTrace"]["selectedRuntime"],
10288                        Value::String("rust-extension-dispatcher".to_string())
10289                    );
10290                    assert_eq!(
10291                        details["dispatcherDecisionTrace"]["schemaPath"],
10292                        Value::String("ExtensionBody::HostCall/HostCallPayload".to_string())
10293                    );
10294                    assert_eq!(
10295                        details["dispatcherDecisionTrace"]["schemaVersion"],
10296                        Value::String(PROTOCOL_VERSION.to_string())
10297                    );
10298                    assert_eq!(
10299                        details["dispatcherDecisionTrace"]["fallbackReason"],
10300                        Value::String("schema_validation_failed".to_string())
10301                    );
10302                    assert_eq!(
10303                        details["extensionInput"]["method"],
10304                        Value::String("session".to_string())
10305                    );
10306                    assert_eq!(
10307                        details["extensionOutput"]["code"],
10308                        Value::String("invalid_request".to_string())
10309                    );
10310                }
10311                other => panic!(),
10312            }
10313        });
10314    }
10315
10316    #[test]
10317    fn protocol_adapter_unknown_method_includes_fallback_trace() {
10318        futures::executor::block_on(async {
10319            let runtime = Rc::new(
10320                PiJsRuntime::with_clock(DeterministicClock::new(0))
10321                    .await
10322                    .expect("runtime"),
10323            );
10324            let dispatcher = build_dispatcher(Rc::clone(&runtime));
10325            let message = ExtensionMessage {
10326                id: "msg-hostcall-unknown-method".to_string(),
10327                version: PROTOCOL_VERSION.to_string(),
10328                body: ExtensionBody::HostCall(HostCallPayload {
10329                    call_id: "call-hostcall-unknown-method".to_string(),
10330                    capability: "session".to_string(),
10331                    method: "not_a_real_method".to_string(),
10332                    params: serde_json::json!({ "foo": 1 }),
10333                    timeout_ms: None,
10334                    cancel_token: None,
10335                    context: None,
10336                }),
10337            };
10338
10339            let response = dispatcher
10340                .dispatch_protocol_message(message)
10341                .await
10342                .expect("protocol dispatch");
10343
10344            match response.body {
10345                ExtensionBody::HostResult(result) => {
10346                    assert!(result.is_error, "expected error host_result");
10347                    let error = result.error.expect("error payload");
10348                    assert_eq!(
10349                        error.code,
10350                        crate::extensions::HostCallErrorCode::InvalidRequest
10351                    );
10352                    let details = error.details.expect("error details");
10353                    assert_eq!(
10354                        details["dispatcherDecisionTrace"]["fallbackReason"],
10355                        Value::String("unsupported_method_fallback".to_string())
10356                    );
10357                    assert_eq!(
10358                        details["dispatcherDecisionTrace"]["method"],
10359                        Value::String("not_a_real_method".to_string())
10360                    );
10361                    assert_eq!(
10362                        details["schemaDiff"]["observedParamKeys"],
10363                        Value::Array(vec![Value::String("foo".to_string())])
10364                    );
10365                    assert_eq!(
10366                        details["extensionInput"]["params"]["foo"],
10367                        Value::Number(serde_json::Number::from(1))
10368                    );
10369                }
10370                other => panic!(),
10371            }
10372        });
10373    }
10374
10375    #[test]
10376    fn dispatch_events_list_unknown_extension_returns_empty_events() {
10377        futures::executor::block_on(async {
10378            let runtime = Rc::new(
10379                PiJsRuntime::with_clock(DeterministicClock::new(0))
10380                    .await
10381                    .expect("runtime"),
10382            );
10383            let dispatcher = build_dispatcher(Rc::clone(&runtime));
10384
10385            let outcome = dispatcher
10386                .dispatch_events(
10387                    "call-events-unknown-extension",
10388                    Some("missing.extension"),
10389                    "list",
10390                    serde_json::json!({}),
10391                )
10392                .await;
10393
10394            match outcome {
10395                HostcallOutcome::Success(value) => {
10396                    assert_eq!(value, serde_json::json!({ "events": [] }));
10397                }
10398                HostcallOutcome::Error { code, message } => {
10399                    panic!();
10400                }
10401                HostcallOutcome::StreamChunk { .. } => {
10402                    panic!();
10403                }
10404            }
10405        });
10406    }
10407
10408    #[test]
10409    fn protocol_adapter_rejects_non_host_call_messages() {
10410        futures::executor::block_on(async {
10411            let runtime = Rc::new(
10412                PiJsRuntime::with_clock(DeterministicClock::new(0))
10413                    .await
10414                    .expect("runtime"),
10415            );
10416            let dispatcher = build_dispatcher(Rc::clone(&runtime));
10417            let message = ExtensionMessage {
10418                id: "msg-hostcall-3".to_string(),
10419                version: PROTOCOL_VERSION.to_string(),
10420                body: ExtensionBody::ToolResult(crate::extensions::ToolResultPayload {
10421                    call_id: "tool-1".to_string(),
10422                    output: serde_json::json!({}),
10423                    is_error: false,
10424                }),
10425            };
10426
10427            let err = dispatcher
10428                .dispatch_protocol_message(message)
10429                .await
10430                .expect_err("non-host-call should fail");
10431            assert!(
10432                err.to_string()
10433                    .contains("dispatch_protocol_message expects host_call"),
10434                "unexpected error: {err}"
10435            );
10436        });
10437    }
10438
10439    // -----------------------------------------------------------------------
10440    // Policy enforcement tests
10441    // -----------------------------------------------------------------------
10442
10443    #[test]
10444    fn dispatch_denied_capability_returns_error() {
10445        futures::executor::block_on(async {
10446            let runtime = Rc::new(
10447                PiJsRuntime::with_clock(DeterministicClock::new(0))
10448                    .await
10449                    .expect("runtime"),
10450            );
10451
10452            // Set up JS promise handler for pi.exec()
10453            runtime
10454                .eval(
10455                    r#"
10456                    globalThis.err = null;
10457                    pi.exec("echo", ["hello"]).catch((e) => { globalThis.err = e; });
10458                "#,
10459                )
10460                .await
10461                .expect("eval");
10462
10463            let requests = runtime.drain_hostcall_requests();
10464            assert_eq!(requests.len(), 1);
10465
10466            // Safe profile denies "exec"
10467            let policy = ExtensionPolicy::from_profile(PolicyProfile::Safe);
10468            let dispatcher = build_dispatcher_with_policy(Rc::clone(&runtime), policy);
10469
10470            for request in requests {
10471                dispatcher.dispatch_and_complete(request).await;
10472            }
10473
10474            let _ = runtime.tick().await.expect("tick");
10475
10476            runtime
10477                .eval(
10478                    r#"
10479                    if (globalThis.err === null) throw new Error("Promise not rejected");
10480                    if (globalThis.err.code !== "denied") {
10481                        throw new Error("Expected denied code, got: " + globalThis.err.code);
10482                    }
10483                "#,
10484                )
10485                .await
10486                .expect("verify denied error");
10487        });
10488    }
10489
10490    #[test]
10491    fn dispatch_denied_capability_still_denied_when_advanced_path_disabled() {
10492        futures::executor::block_on(async {
10493            let runtime = Rc::new(
10494                PiJsRuntime::with_clock(DeterministicClock::new(0))
10495                    .await
10496                    .expect("runtime"),
10497            );
10498
10499            runtime
10500                .eval(
10501                    r#"
10502                    globalThis.err = null;
10503                    pi.exec("echo", ["hello"]).catch((e) => { globalThis.err = e; });
10504                "#,
10505                )
10506                .await
10507                .expect("eval");
10508
10509            let requests = runtime.drain_hostcall_requests();
10510            assert_eq!(requests.len(), 1);
10511
10512            let oracle_config = DualExecOracleConfig {
10513                sample_ppm: 0,
10514                ..DualExecOracleConfig::default()
10515            };
10516            let policy = ExtensionPolicy::from_profile(PolicyProfile::Safe);
10517            let mut dispatcher =
10518                build_dispatcher_with_policy_and_oracle(Rc::clone(&runtime), policy, oracle_config);
10519            dispatcher.io_uring_lane_config = IoUringLanePolicyConfig::conservative();
10520            dispatcher.io_uring_force_compat = false;
10521            assert!(
10522                !dispatcher.advanced_dispatch_enabled(),
10523                "advanced path should be disabled for this test"
10524            );
10525
10526            for request in requests {
10527                dispatcher.dispatch_and_complete(request).await;
10528            }
10529
10530            let _ = runtime.tick().await.expect("tick");
10531
10532            runtime
10533                .eval(
10534                    r#"
10535                    if (globalThis.err === null) throw new Error("Promise not rejected");
10536                    if (globalThis.err.code !== "denied") {
10537                        throw new Error("Expected denied code, got: " + globalThis.err.code);
10538                    }
10539                "#,
10540                )
10541                .await
10542                .expect("verify denied error");
10543        });
10544    }
10545
10546    #[test]
10547    fn dispatch_allowed_capability_proceeds() {
10548        futures::executor::block_on(async {
10549            let runtime = Rc::new(
10550                PiJsRuntime::with_clock(DeterministicClock::new(0))
10551                    .await
10552                    .expect("runtime"),
10553            );
10554
10555            runtime
10556                .eval(
10557                    r#"
10558                    globalThis.result = null;
10559                    pi.log("test message").then((r) => { globalThis.result = r; });
10560                "#,
10561                )
10562                .await
10563                .expect("eval");
10564
10565            let requests = runtime.drain_hostcall_requests();
10566            assert_eq!(requests.len(), 1);
10567
10568            let policy = ExtensionPolicy::from_profile(PolicyProfile::Permissive);
10569            let dispatcher = build_dispatcher_with_policy(Rc::clone(&runtime), policy);
10570
10571            for request in requests {
10572                dispatcher.dispatch_and_complete(request).await;
10573            }
10574
10575            let _ = runtime.tick().await.expect("tick");
10576
10577            runtime
10578                .eval(
10579                    r#"
10580                    if (globalThis.result === null) throw new Error("Promise not resolved");
10581                "#,
10582                )
10583                .await
10584                .expect("verify allowed");
10585        });
10586    }
10587
10588    #[test]
10589    fn dispatch_allowed_capability_still_resolves_when_advanced_path_disabled() {
10590        futures::executor::block_on(async {
10591            let runtime = Rc::new(
10592                PiJsRuntime::with_clock(DeterministicClock::new(0))
10593                    .await
10594                    .expect("runtime"),
10595            );
10596
10597            runtime
10598                .eval(
10599                    r#"
10600                    globalThis.result = null;
10601                    pi.log("test message").then((r) => { globalThis.result = r; });
10602                "#,
10603                )
10604                .await
10605                .expect("eval");
10606
10607            let requests = runtime.drain_hostcall_requests();
10608            assert_eq!(requests.len(), 1);
10609
10610            let oracle_config = DualExecOracleConfig {
10611                sample_ppm: 0,
10612                ..DualExecOracleConfig::default()
10613            };
10614            let policy = ExtensionPolicy::from_profile(PolicyProfile::Permissive);
10615            let mut dispatcher =
10616                build_dispatcher_with_policy_and_oracle(Rc::clone(&runtime), policy, oracle_config);
10617            dispatcher.io_uring_lane_config = IoUringLanePolicyConfig::conservative();
10618            dispatcher.io_uring_force_compat = false;
10619            assert!(
10620                !dispatcher.advanced_dispatch_enabled(),
10621                "advanced path should be disabled for this test"
10622            );
10623
10624            for request in requests {
10625                dispatcher.dispatch_and_complete(request).await;
10626            }
10627
10628            let _ = runtime.tick().await.expect("tick");
10629
10630            runtime
10631                .eval(
10632                    r#"
10633                    if (globalThis.result === null) throw new Error("Promise not resolved");
10634                "#,
10635                )
10636                .await
10637                .expect("verify allowed");
10638        });
10639    }
10640
10641    #[test]
10642    fn advanced_dispatch_enabled_when_dual_exec_sampling_non_zero() {
10643        futures::executor::block_on(async {
10644            let runtime = Rc::new(
10645                PiJsRuntime::with_clock(DeterministicClock::new(0))
10646                    .await
10647                    .expect("runtime"),
10648            );
10649            let oracle_config = DualExecOracleConfig {
10650                sample_ppm: 1,
10651                ..DualExecOracleConfig::default()
10652            };
10653            let dispatcher = build_dispatcher_with_policy_and_oracle(
10654                Rc::clone(&runtime),
10655                ExtensionPolicy::from_profile(PolicyProfile::Permissive),
10656                oracle_config,
10657            );
10658            assert!(dispatcher.advanced_dispatch_enabled());
10659        });
10660    }
10661
10662    #[test]
10663    fn advanced_dispatch_enabled_when_io_uring_is_enabled() {
10664        futures::executor::block_on(async {
10665            let runtime = Rc::new(
10666                PiJsRuntime::with_clock(DeterministicClock::new(0))
10667                    .await
10668                    .expect("runtime"),
10669            );
10670            let oracle_config = DualExecOracleConfig {
10671                sample_ppm: 0,
10672                ..DualExecOracleConfig::default()
10673            };
10674            let mut dispatcher = build_dispatcher_with_policy_and_oracle(
10675                Rc::clone(&runtime),
10676                ExtensionPolicy::from_profile(PolicyProfile::Permissive),
10677                oracle_config,
10678            );
10679            dispatcher.io_uring_lane_config = IoUringLanePolicyConfig {
10680                enabled: true,
10681                ring_available: true,
10682                max_queue_depth: 256,
10683                allow_filesystem: true,
10684                allow_network: true,
10685            };
10686            assert!(dispatcher.advanced_dispatch_enabled());
10687        });
10688    }
10689
10690    #[test]
10691    fn advanced_dispatch_enabled_when_io_uring_force_compat_is_set() {
10692        futures::executor::block_on(async {
10693            let runtime = Rc::new(
10694                PiJsRuntime::with_clock(DeterministicClock::new(0))
10695                    .await
10696                    .expect("runtime"),
10697            );
10698            let oracle_config = DualExecOracleConfig {
10699                sample_ppm: 0,
10700                ..DualExecOracleConfig::default()
10701            };
10702            let mut dispatcher = build_dispatcher_with_policy_and_oracle(
10703                Rc::clone(&runtime),
10704                ExtensionPolicy::from_profile(PolicyProfile::Permissive),
10705                oracle_config,
10706            );
10707            dispatcher.io_uring_lane_config = IoUringLanePolicyConfig::conservative();
10708            dispatcher.io_uring_force_compat = true;
10709            assert!(dispatcher.advanced_dispatch_enabled());
10710        });
10711    }
10712
10713    #[test]
10714    fn dispatch_strict_mode_denies_unknown_capability() {
10715        futures::executor::block_on(async {
10716            let runtime = Rc::new(
10717                PiJsRuntime::with_clock(DeterministicClock::new(0))
10718                    .await
10719                    .expect("runtime"),
10720            );
10721
10722            runtime
10723                .eval(
10724                    r#"
10725                    globalThis.err = null;
10726                    pi.http({ url: "http://localhost" }).catch((e) => { globalThis.err = e; });
10727                "#,
10728                )
10729                .await
10730                .expect("eval");
10731
10732            let requests = runtime.drain_hostcall_requests();
10733            assert_eq!(requests.len(), 1);
10734
10735            // Strict mode with no default_caps: everything denied
10736            let policy = ExtensionPolicy {
10737                mode: ExtensionPolicyMode::Strict,
10738                max_memory_mb: 256,
10739                default_caps: Vec::new(),
10740                deny_caps: Vec::new(),
10741                per_extension: HashMap::new(),
10742                ..Default::default()
10743            };
10744            let dispatcher = build_dispatcher_with_policy(Rc::clone(&runtime), policy);
10745
10746            for request in requests {
10747                dispatcher.dispatch_and_complete(request).await;
10748            }
10749
10750            let _ = runtime.tick().await.expect("tick");
10751
10752            runtime
10753                .eval(
10754                    r#"
10755                    if (globalThis.err === null) throw new Error("Promise not rejected");
10756                    if (globalThis.err.code !== "denied") {
10757                        throw new Error("Expected denied code, got: " + globalThis.err.code);
10758                    }
10759                "#,
10760                )
10761                .await
10762                .expect("verify strict denied");
10763        });
10764    }
10765
10766    #[test]
10767    fn protocol_dispatch_denied_returns_error() {
10768        futures::executor::block_on(async {
10769            let runtime = Rc::new(
10770                PiJsRuntime::with_clock(DeterministicClock::new(0))
10771                    .await
10772                    .expect("runtime"),
10773            );
10774            // Safe profile denies "exec"
10775            let policy = ExtensionPolicy::from_profile(PolicyProfile::Safe);
10776            let dispatcher = build_dispatcher_with_policy(Rc::clone(&runtime), policy);
10777
10778            let message = ExtensionMessage {
10779                id: "msg-policy-deny".to_string(),
10780                version: PROTOCOL_VERSION.to_string(),
10781                body: ExtensionBody::HostCall(HostCallPayload {
10782                    call_id: "call-policy-deny".to_string(),
10783                    capability: "exec".to_string(),
10784                    method: "exec".to_string(),
10785                    params: serde_json::json!({ "cmd": "echo hello" }),
10786                    timeout_ms: None,
10787                    cancel_token: None,
10788                    context: None,
10789                }),
10790            };
10791
10792            let response = dispatcher
10793                .dispatch_protocol_message(message)
10794                .await
10795                .expect("protocol dispatch");
10796
10797            match response.body {
10798                ExtensionBody::HostResult(result) => {
10799                    assert!(result.is_error, "expected denied error result");
10800                    let error = result.error.expect("error payload");
10801                    assert_eq!(error.code, HostCallErrorCode::Denied);
10802                    assert!(
10803                        error.message.contains("exec"),
10804                        "error should mention denied capability: {}",
10805                        error.message
10806                    );
10807                }
10808                other => panic!(),
10809            }
10810        });
10811    }
10812
10813    #[test]
10814    fn dispatch_deny_caps_blocks_http() {
10815        futures::executor::block_on(async {
10816            let runtime = Rc::new(
10817                PiJsRuntime::with_clock(DeterministicClock::new(0))
10818                    .await
10819                    .expect("runtime"),
10820            );
10821
10822            runtime
10823                .eval(
10824                    r#"
10825                    globalThis.err = null;
10826                    pi.http({ url: "http://localhost" }).catch((e) => { globalThis.err = e; });
10827                "#,
10828                )
10829                .await
10830                .expect("eval");
10831
10832            let requests = runtime.drain_hostcall_requests();
10833            assert_eq!(requests.len(), 1);
10834
10835            let policy = ExtensionPolicy {
10836                mode: ExtensionPolicyMode::Permissive,
10837                max_memory_mb: 256,
10838                default_caps: Vec::new(),
10839                deny_caps: vec!["http".to_string()],
10840                per_extension: HashMap::new(),
10841                ..Default::default()
10842            };
10843            let dispatcher = build_dispatcher_with_policy(Rc::clone(&runtime), policy);
10844
10845            for request in requests {
10846                dispatcher.dispatch_and_complete(request).await;
10847            }
10848
10849            let _ = runtime.tick().await.expect("tick");
10850
10851            runtime
10852                .eval(
10853                    r#"
10854                    if (globalThis.err === null) throw new Error("Promise not rejected");
10855                    if (globalThis.err.code !== "denied") {
10856                        throw new Error("Expected denied code, got: " + globalThis.err.code);
10857                    }
10858                "#,
10859                )
10860                .await
10861                .expect("verify deny_caps http blocked");
10862        });
10863    }
10864
10865    #[test]
10866    fn per_extension_deny_blocks_specific_extension() {
10867        futures::executor::block_on(async {
10868            let runtime = Rc::new(
10869                PiJsRuntime::with_clock(DeterministicClock::new(0))
10870                    .await
10871                    .expect("runtime"),
10872            );
10873
10874            // Trigger a session hostcall from JS
10875            runtime
10876                .eval(
10877                    r#"
10878                    globalThis.err = null;
10879                    globalThis.result = null;
10880                    pi.session("getState", {}).catch((e) => { globalThis.err = e; })
10881                        .then((r) => { if (r) globalThis.result = r; });
10882                "#,
10883                )
10884                .await
10885                .expect("eval");
10886
10887            let requests = runtime.drain_hostcall_requests();
10888            assert_eq!(requests.len(), 1);
10889
10890            let mut per_extension = HashMap::new();
10891            per_extension.insert(
10892                "blocked-ext".to_string(),
10893                ExtensionOverride {
10894                    mode: None,
10895                    allow: Vec::new(),
10896                    deny: vec!["session".to_string()],
10897                    quota: None,
10898                },
10899            );
10900            let policy = ExtensionPolicy {
10901                mode: ExtensionPolicyMode::Permissive,
10902                max_memory_mb: 256,
10903                default_caps: Vec::new(),
10904                deny_caps: Vec::new(),
10905                per_extension,
10906                ..Default::default()
10907            };
10908            let dispatcher = build_dispatcher_with_policy(Rc::clone(&runtime), policy);
10909
10910            // Modify the request to come from the blocked extension
10911            let mut request = requests.into_iter().next().unwrap();
10912            request.extension_id = Some("blocked-ext".to_string());
10913
10914            dispatcher.dispatch_and_complete(request).await;
10915
10916            let _ = runtime.tick().await.expect("tick");
10917
10918            runtime
10919                .eval(
10920                    r#"
10921                    if (globalThis.err === null) throw new Error("Promise not rejected");
10922                    if (globalThis.err.code !== "denied") {
10923                        throw new Error("Expected denied code, got: " + globalThis.err.code);
10924                    }
10925                "#,
10926                )
10927                .await
10928                .expect("verify per-extension deny");
10929        });
10930    }
10931
10932    #[test]
10933    fn prompt_decision_treated_as_deny_in_dispatcher() {
10934        futures::executor::block_on(async {
10935            let runtime = Rc::new(
10936                PiJsRuntime::with_clock(DeterministicClock::new(0))
10937                    .await
10938                    .expect("runtime"),
10939            );
10940
10941            runtime
10942                .eval(
10943                    r#"
10944                    globalThis.err = null;
10945                    pi.exec("echo", ["hello"]).catch((e) => { globalThis.err = e; });
10946                "#,
10947                )
10948                .await
10949                .expect("eval");
10950
10951            let requests = runtime.drain_hostcall_requests();
10952            assert_eq!(requests.len(), 1);
10953
10954            // Prompt mode with no defaults → exec falls through to Prompt
10955            let policy = ExtensionPolicy {
10956                mode: ExtensionPolicyMode::Prompt,
10957                max_memory_mb: 256,
10958                default_caps: Vec::new(),
10959                deny_caps: Vec::new(),
10960                per_extension: HashMap::new(),
10961                ..Default::default()
10962            };
10963            let dispatcher = build_dispatcher_with_policy(Rc::clone(&runtime), policy);
10964
10965            for request in requests {
10966                dispatcher.dispatch_and_complete(request).await;
10967            }
10968
10969            let _ = runtime.tick().await.expect("tick");
10970
10971            runtime
10972                .eval(
10973                    r#"
10974                    if (globalThis.err === null) throw new Error("Promise not rejected");
10975                    if (globalThis.err.code !== "denied") {
10976                        throw new Error("Expected denied, got: " + globalThis.err.code);
10977                    }
10978                "#,
10979                )
10980                .await
10981                .expect("verify prompt treated as deny");
10982        });
10983    }
10984
10985    // -----------------------------------------------------------------------
10986    // Utility function unit tests
10987    // -----------------------------------------------------------------------
10988
10989    #[test]
10990    fn protocol_hostcall_op_extracts_op_field() {
10991        let params = serde_json::json!({ "op": "get_state" });
10992        assert_eq!(protocol_hostcall_op(&params), Some("get_state"));
10993    }
10994
10995    #[test]
10996    fn protocol_hostcall_op_extracts_method_field() {
10997        let params = serde_json::json!({ "method": "do_thing" });
10998        assert_eq!(protocol_hostcall_op(&params), Some("do_thing"));
10999    }
11000
11001    #[test]
11002    fn protocol_hostcall_op_extracts_name_field() {
11003        let params = serde_json::json!({ "name": "my_event" });
11004        assert_eq!(protocol_hostcall_op(&params), Some("my_event"));
11005    }
11006
11007    #[test]
11008    fn protocol_hostcall_op_prefers_op_over_method_and_name() {
11009        let params = serde_json::json!({ "op": "a", "method": "b", "name": "c" });
11010        assert_eq!(protocol_hostcall_op(&params), Some("a"));
11011    }
11012
11013    #[test]
11014    fn protocol_hostcall_op_falls_back_to_method_when_op_missing() {
11015        let params = serde_json::json!({ "method": "b", "name": "c" });
11016        assert_eq!(protocol_hostcall_op(&params), Some("b"));
11017    }
11018
11019    #[test]
11020    fn protocol_hostcall_op_returns_none_for_empty_or_whitespace() {
11021        assert_eq!(protocol_hostcall_op(&serde_json::json!({})), None);
11022        assert_eq!(protocol_hostcall_op(&serde_json::json!({ "op": "" })), None);
11023        assert_eq!(
11024            protocol_hostcall_op(&serde_json::json!({ "op": "   " })),
11025            None
11026        );
11027    }
11028
11029    #[test]
11030    fn protocol_hostcall_op_trims_whitespace() {
11031        let params = serde_json::json!({ "op": "  get_state  " });
11032        assert_eq!(protocol_hostcall_op(&params), Some("get_state"));
11033    }
11034
11035    #[test]
11036    fn protocol_hostcall_op_returns_none_for_non_string_values() {
11037        assert_eq!(protocol_hostcall_op(&serde_json::json!({ "op": 42 })), None);
11038        assert_eq!(
11039            protocol_hostcall_op(&serde_json::json!({ "op": true })),
11040            None
11041        );
11042        assert_eq!(
11043            protocol_hostcall_op(&serde_json::json!({ "op": null })),
11044            None
11045        );
11046    }
11047
11048    #[test]
11049    fn parse_protocol_hostcall_method_normalizes_case_and_whitespace() {
11050        assert!(matches!(
11051            parse_protocol_hostcall_method(" Tool "),
11052            Some(ProtocolHostcallMethod::Tool)
11053        ));
11054        assert!(matches!(
11055            parse_protocol_hostcall_method("EXEC"),
11056            Some(ProtocolHostcallMethod::Exec)
11057        ));
11058        assert!(matches!(
11059            parse_protocol_hostcall_method(" session "),
11060            Some(ProtocolHostcallMethod::Session)
11061        ));
11062    }
11063
11064    #[test]
11065    fn parse_protocol_hostcall_method_rejects_unknown_or_empty_values() {
11066        assert!(parse_protocol_hostcall_method("").is_none());
11067        assert!(parse_protocol_hostcall_method("   ").is_none());
11068        assert!(parse_protocol_hostcall_method("not_a_method").is_none());
11069    }
11070
11071    #[test]
11072    fn protocol_error_fallback_reason_preserves_invalid_request_taxonomy() {
11073        assert_eq!(
11074            protocol_error_fallback_reason("tool", "invalid_request"),
11075            "schema_validation_failed"
11076        );
11077        assert_eq!(
11078            protocol_error_fallback_reason("  SESSION ", "invalid_request"),
11079            "schema_validation_failed"
11080        );
11081        assert_eq!(
11082            protocol_error_fallback_reason("unknown", "invalid_request"),
11083            "unsupported_method_fallback"
11084        );
11085    }
11086
11087    #[test]
11088    fn protocol_error_fallback_reason_maps_non_invalid_request_codes() {
11089        assert_eq!(
11090            protocol_error_fallback_reason("tool", "denied"),
11091            "policy_denied"
11092        );
11093        assert_eq!(
11094            protocol_error_fallback_reason("tool", "timeout"),
11095            "handler_timeout"
11096        );
11097        assert_eq!(
11098            protocol_error_fallback_reason("tool", "tool_error"),
11099            "handler_error"
11100        );
11101        assert_eq!(
11102            protocol_error_fallback_reason("tool", "unexpected"),
11103            "runtime_internal_error"
11104        );
11105    }
11106
11107    #[test]
11108    fn protocol_normalize_output_passes_object_through() {
11109        let obj = serde_json::json!({ "key": "value" });
11110        assert_eq!(protocol_normalize_output(obj.clone()), obj);
11111    }
11112
11113    #[test]
11114    fn protocol_normalize_output_wraps_non_object_in_value_field() {
11115        assert_eq!(
11116            protocol_normalize_output(serde_json::json!("hello")),
11117            serde_json::json!({ "value": "hello" })
11118        );
11119        assert_eq!(
11120            protocol_normalize_output(serde_json::json!(42)),
11121            serde_json::json!({ "value": 42 })
11122        );
11123        assert_eq!(
11124            protocol_normalize_output(serde_json::json!(true)),
11125            serde_json::json!({ "value": true })
11126        );
11127        assert_eq!(
11128            protocol_normalize_output(Value::Null),
11129            serde_json::json!({ "value": null })
11130        );
11131        assert_eq!(
11132            protocol_normalize_output(serde_json::json!([1, 2, 3])),
11133            serde_json::json!({ "value": [1, 2, 3] })
11134        );
11135    }
11136
11137    #[test]
11138    fn protocol_error_code_maps_known_codes() {
11139        assert_eq!(protocol_error_code("timeout"), HostCallErrorCode::Timeout);
11140        assert_eq!(protocol_error_code("denied"), HostCallErrorCode::Denied);
11141        assert_eq!(protocol_error_code("io"), HostCallErrorCode::Io);
11142        assert_eq!(protocol_error_code("tool_error"), HostCallErrorCode::Io);
11143        assert_eq!(
11144            protocol_error_code("invalid_request"),
11145            HostCallErrorCode::InvalidRequest
11146        );
11147    }
11148
11149    #[test]
11150    fn protocol_error_code_unknown_maps_to_internal() {
11151        assert_eq!(
11152            protocol_error_code("something_else"),
11153            HostCallErrorCode::Internal
11154        );
11155        assert_eq!(protocol_error_code(""), HostCallErrorCode::Internal);
11156        assert_eq!(
11157            protocol_error_code("not_a_code"),
11158            HostCallErrorCode::Internal
11159        );
11160    }
11161
11162    #[test]
11163    fn protocol_error_code_normalizes_case_and_whitespace() {
11164        assert_eq!(protocol_error_code(" Timeout "), HostCallErrorCode::Timeout);
11165        assert_eq!(protocol_error_code("DENIED"), HostCallErrorCode::Denied);
11166        assert_eq!(protocol_error_code(" Tool_Error "), HostCallErrorCode::Io);
11167        assert_eq!(
11168            protocol_error_code(" Invalid_Request "),
11169            HostCallErrorCode::InvalidRequest
11170        );
11171    }
11172
11173    #[test]
11174    fn protocol_error_fallback_reason_normalizes_code_before_taxonomy_mapping() {
11175        assert_eq!(
11176            protocol_error_fallback_reason(" session ", " INVALID_REQUEST "),
11177            "schema_validation_failed"
11178        );
11179        assert_eq!(
11180            protocol_error_fallback_reason("unknown", " INVALID_REQUEST "),
11181            "unsupported_method_fallback"
11182        );
11183        assert_eq!(
11184            protocol_error_fallback_reason("tool", " TOOL_ERROR "),
11185            "handler_error"
11186        );
11187    }
11188
11189    fn test_protocol_payload(call_id: &str) -> HostCallPayload {
11190        HostCallPayload {
11191            call_id: call_id.to_string(),
11192            capability: "test".to_string(),
11193            method: "tool".to_string(),
11194            params: serde_json::json!({}),
11195            timeout_ms: None,
11196            cancel_token: None,
11197            context: None,
11198        }
11199    }
11200
11201    fn test_hostcall_request(call_id: &str, kind: HostcallKind, payload: Value) -> HostcallRequest {
11202        HostcallRequest {
11203            call_id: call_id.to_string(),
11204            kind,
11205            payload,
11206            trace_id: 0,
11207            extension_id: Some("ext.protocol.params".to_string()),
11208        }
11209    }
11210
11211    #[test]
11212    fn protocol_params_from_request_matches_hostcall_request_params_for_hash() {
11213        let requests = vec![
11214            test_hostcall_request(
11215                "tool-case",
11216                HostcallKind::Tool {
11217                    name: "read".to_string(),
11218                },
11219                serde_json::json!({ "path": "README.md" }),
11220            ),
11221            test_hostcall_request(
11222                "tool-non-object-case",
11223                HostcallKind::Tool {
11224                    name: "read".to_string(),
11225                },
11226                serde_json::json!(["README.md", "Cargo.toml"]),
11227            ),
11228            test_hostcall_request(
11229                "exec-object-case",
11230                HostcallKind::Exec {
11231                    cmd: "echo from kind".to_string(),
11232                },
11233                serde_json::json!({
11234                    "command": "legacy alias should be removed",
11235                    "cmd": "payload override should lose",
11236                    "args": ["hello"],
11237                }),
11238            ),
11239            test_hostcall_request(
11240                "exec-non-object-case",
11241                HostcallKind::Exec {
11242                    cmd: "bash -lc true".to_string(),
11243                },
11244                serde_json::json!("raw payload"),
11245            ),
11246            test_hostcall_request(
11247                "http-case",
11248                HostcallKind::Http,
11249                serde_json::json!({
11250                    "url": "https://example.com",
11251                    "method": "GET",
11252                }),
11253            ),
11254            test_hostcall_request(
11255                "http-non-object-case",
11256                HostcallKind::Http,
11257                serde_json::json!("https://example.com/raw"),
11258            ),
11259            test_hostcall_request(
11260                "session-case",
11261                HostcallKind::Session {
11262                    op: "get_state".to_string(),
11263                },
11264                serde_json::json!({
11265                    "op": "payload override should lose",
11266                    "includeEntries": true,
11267                }),
11268            ),
11269            test_hostcall_request(
11270                "ui-non-object-case",
11271                HostcallKind::Ui {
11272                    op: "set_status".to_string(),
11273                },
11274                serde_json::json!("ready"),
11275            ),
11276            test_hostcall_request(
11277                "events-null-case",
11278                HostcallKind::Events {
11279                    op: "list_flags".to_string(),
11280                },
11281                Value::Null,
11282            ),
11283            test_hostcall_request(
11284                "log-case",
11285                HostcallKind::Log,
11286                serde_json::json!({
11287                    "level": "info",
11288                    "event": "test.protocol",
11289                    "message": "hello",
11290                }),
11291            ),
11292            test_hostcall_request(
11293                "log-non-object-case",
11294                HostcallKind::Log,
11295                serde_json::json!("raw-log-payload"),
11296            ),
11297            test_hostcall_request(
11298                "log-array-case",
11299                HostcallKind::Log,
11300                serde_json::json!(["raw", "log", "payload"]),
11301            ),
11302            test_hostcall_request("log-null-case", HostcallKind::Log, Value::Null),
11303        ];
11304
11305        for request in requests {
11306            assert_eq!(
11307                protocol_params_from_request(&request),
11308                request.params_for_hash(),
11309                "protocol params shape diverged for {}",
11310                request.call_id
11311            );
11312        }
11313    }
11314
11315    #[test]
11316    fn protocol_params_from_request_preserves_reserved_key_precedence() {
11317        let exec_request = test_hostcall_request(
11318            "exec-precedence",
11319            HostcallKind::Exec {
11320                cmd: "echo from kind".to_string(),
11321            },
11322            serde_json::json!({
11323                "command": "legacy alias",
11324                "cmd": "payload cmd should not win",
11325                "args": ["a", "b"],
11326            }),
11327        );
11328        let exec_params = protocol_params_from_request(&exec_request);
11329        assert_eq!(exec_params["cmd"], serde_json::json!("echo from kind"));
11330        assert_eq!(exec_params.get("command"), None);
11331
11332        for (call_id, kind) in [
11333            (
11334                "session-precedence",
11335                HostcallKind::Session {
11336                    op: "get_state".to_string(),
11337                },
11338            ),
11339            (
11340                "ui-precedence",
11341                HostcallKind::Ui {
11342                    op: "set_status".to_string(),
11343                },
11344            ),
11345            (
11346                "events-precedence",
11347                HostcallKind::Events {
11348                    op: "list_flags".to_string(),
11349                },
11350            ),
11351        ] {
11352            let request = test_hostcall_request(
11353                call_id,
11354                kind.clone(),
11355                serde_json::json!({ "op": "payload op should not win", "x": 1 }),
11356            );
11357            let params = protocol_params_from_request(&request);
11358            let expected_op = match kind {
11359                HostcallKind::Session { ref op }
11360                | HostcallKind::Ui { ref op }
11361                | HostcallKind::Events { ref op } => op.clone(),
11362                _ => unreachable!("loop only includes op-based hostcall kinds"),
11363            };
11364            assert_eq!(params["op"], Value::String(expected_op));
11365        }
11366    }
11367
11368    fn assert_protocol_result_equivalent_except_error_details(
11369        plain: &HostResultPayload,
11370        traced: &HostResultPayload,
11371    ) {
11372        assert_eq!(plain.call_id, traced.call_id);
11373        assert_eq!(plain.output, traced.output);
11374        assert_eq!(plain.is_error, traced.is_error);
11375        assert_eq!(
11376            plain.chunk.as_ref().map(|chunk| {
11377                (
11378                    chunk.index,
11379                    chunk.is_last,
11380                    chunk
11381                        .backpressure
11382                        .as_ref()
11383                        .map(|bp| (bp.credits, bp.delay_ms)),
11384                )
11385            }),
11386            traced.chunk.as_ref().map(|chunk| {
11387                (
11388                    chunk.index,
11389                    chunk.is_last,
11390                    chunk
11391                        .backpressure
11392                        .as_ref()
11393                        .map(|bp| (bp.credits, bp.delay_ms)),
11394                )
11395            })
11396        );
11397        match (plain.error.as_ref(), traced.error.as_ref()) {
11398            (None, None) => {}
11399            (Some(plain_error), Some(traced_error)) => {
11400                assert_eq!(plain_error.code, traced_error.code);
11401                assert_eq!(plain_error.message, traced_error.message);
11402                assert_eq!(plain_error.retryable, traced_error.retryable);
11403            }
11404            _ => panic!(),
11405        }
11406    }
11407
11408    #[test]
11409    fn hostcall_outcome_to_protocol_result_success() {
11410        let payload = test_protocol_payload("call-1");
11411        let result = hostcall_outcome_to_protocol_result(
11412            &payload.call_id,
11413            HostcallOutcome::Success(serde_json::json!({ "ok": true })),
11414        );
11415        assert_eq!(result.call_id, "call-1");
11416        assert!(!result.is_error);
11417        assert!(result.error.is_none());
11418        assert!(result.chunk.is_none());
11419        assert!(result.output.is_object());
11420    }
11421
11422    #[test]
11423    fn hostcall_outcome_to_protocol_result_success_wraps_non_object() {
11424        let payload = test_protocol_payload("call-2");
11425        let result = hostcall_outcome_to_protocol_result(
11426            &payload.call_id,
11427            HostcallOutcome::Success(serde_json::json!("plain string")),
11428        );
11429        assert!(!result.is_error);
11430        assert_eq!(
11431            result.output,
11432            serde_json::json!({ "value": "plain string" })
11433        );
11434    }
11435
11436    #[test]
11437    fn hostcall_outcome_to_protocol_result_stream_chunk() {
11438        let payload = test_protocol_payload("call-3");
11439        let result = hostcall_outcome_to_protocol_result(
11440            &payload.call_id,
11441            HostcallOutcome::StreamChunk {
11442                sequence: 5,
11443                chunk: serde_json::json!({ "stdout": "hello\n" }),
11444                is_final: false,
11445            },
11446        );
11447        assert_eq!(result.call_id, "call-3");
11448        assert!(!result.is_error);
11449        assert!(result.error.is_none());
11450        let chunk = result.chunk.expect("should have chunk");
11451        assert_eq!(chunk.index, 5);
11452        assert!(!chunk.is_last);
11453        assert_eq!(result.output["sequence"], 5);
11454        assert!(!result.output["isFinal"].as_bool().unwrap());
11455    }
11456
11457    #[test]
11458    fn hostcall_outcome_to_protocol_result_stream_chunk_final() {
11459        let payload = test_protocol_payload("call-4");
11460        let result = hostcall_outcome_to_protocol_result(
11461            &payload.call_id,
11462            HostcallOutcome::StreamChunk {
11463                sequence: 10,
11464                chunk: serde_json::json!({ "code": 0 }),
11465                is_final: true,
11466            },
11467        );
11468        let chunk = result.chunk.expect("should have chunk");
11469        assert!(chunk.is_last);
11470        assert_eq!(chunk.index, 10);
11471        assert!(result.output["isFinal"].as_bool().unwrap());
11472    }
11473
11474    #[test]
11475    fn hostcall_outcome_to_protocol_result_error() {
11476        let payload = test_protocol_payload("call-5");
11477        let result = hostcall_outcome_to_protocol_result(
11478            &payload.call_id,
11479            HostcallOutcome::Error {
11480                code: "io".to_string(),
11481                message: "disk full".to_string(),
11482            },
11483        );
11484        assert_eq!(result.call_id, "call-5");
11485        assert!(result.is_error);
11486        assert!(result.chunk.is_none());
11487        let error = result.error.expect("should have error");
11488        assert_eq!(error.code, HostCallErrorCode::Io);
11489        assert_eq!(error.message, "disk full");
11490    }
11491
11492    #[test]
11493    fn hostcall_outcome_to_protocol_result_error_unknown_code_maps_to_internal() {
11494        let payload = test_protocol_payload("call-6");
11495        let result = hostcall_outcome_to_protocol_result(
11496            &payload.call_id,
11497            HostcallOutcome::Error {
11498                code: "something_weird".to_string(),
11499                message: "unexpected".to_string(),
11500            },
11501        );
11502        let error = result.error.expect("should have error");
11503        assert_eq!(error.code, HostCallErrorCode::Internal);
11504    }
11505
11506    #[test]
11507    fn hostcall_outcome_to_protocol_result_error_normalizes_mixed_case_code() {
11508        let payload = test_protocol_payload("call-6b");
11509        let result = hostcall_outcome_to_protocol_result(
11510            &payload.call_id,
11511            HostcallOutcome::Error {
11512                code: "  Invalid_Request  ".to_string(),
11513                message: "normalized".to_string(),
11514            },
11515        );
11516        let error = result.error.expect("should have error");
11517        assert_eq!(error.code, HostCallErrorCode::InvalidRequest);
11518        assert_eq!(error.message, "normalized");
11519    }
11520
11521    #[test]
11522    fn hostcall_outcome_to_protocol_result_error_normalizes_denied_timeout_and_tool_error_alias() {
11523        let cases = [
11524            ("  DeNied ", HostCallErrorCode::Denied),
11525            ("  TimeOut ", HostCallErrorCode::Timeout),
11526            ("  TOOL_ERROR ", HostCallErrorCode::Io),
11527        ];
11528
11529        for (idx, (raw_code, expected_code)) in cases.into_iter().enumerate() {
11530            let payload = test_protocol_payload(&format!("call-plain-normalize-{idx}"));
11531            let message = format!("normalized-{idx}");
11532            let result = hostcall_outcome_to_protocol_result(
11533                &payload.call_id,
11534                HostcallOutcome::Error {
11535                    code: raw_code.to_string(),
11536                    message: message.clone(),
11537                },
11538            );
11539
11540            let error = result.error.expect("should have error");
11541            assert_eq!(error.code, expected_code, "raw code: {raw_code}");
11542            assert_eq!(error.message, message);
11543        }
11544    }
11545
11546    #[test]
11547    fn hostcall_outcome_to_protocol_result_with_trace_success_equivalent_to_plain() {
11548        let payload = test_protocol_payload("call-trace-success");
11549        let outcome = HostcallOutcome::Success(serde_json::json!({
11550            "ok": true,
11551            "nested": { "n": 7 }
11552        }));
11553        let plain = hostcall_outcome_to_protocol_result(&payload.call_id, outcome.clone());
11554        let traced = hostcall_outcome_to_protocol_result_with_trace(&payload, outcome);
11555
11556        assert_protocol_result_equivalent_except_error_details(&plain, &traced);
11557        assert!(traced.error.is_none());
11558    }
11559
11560    #[test]
11561    fn hostcall_outcome_to_protocol_result_with_trace_stream_equivalent_to_plain() {
11562        let payload = test_protocol_payload("call-trace-stream");
11563        let outcome = HostcallOutcome::StreamChunk {
11564            sequence: 3,
11565            chunk: serde_json::json!({ "stdout": "chunk" }),
11566            is_final: false,
11567        };
11568        let plain = hostcall_outcome_to_protocol_result(&payload.call_id, outcome.clone());
11569        let traced = hostcall_outcome_to_protocol_result_with_trace(&payload, outcome);
11570
11571        assert_protocol_result_equivalent_except_error_details(&plain, &traced);
11572        assert!(traced.error.is_none());
11573    }
11574
11575    #[test]
11576    fn hostcall_outcome_to_protocol_result_with_trace_error_adds_details_without_mutating_error_core()
11577     {
11578        let mut payload = test_protocol_payload("call-trace-error");
11579        payload.method = "tool".to_string();
11580        payload.params = serde_json::json!({ "zeta": 1, "alpha": 2 });
11581        let outcome = HostcallOutcome::Error {
11582            code: "invalid_request".to_string(),
11583            message: "invalid payload".to_string(),
11584        };
11585        let plain = hostcall_outcome_to_protocol_result(&payload.call_id, outcome.clone());
11586        let traced = hostcall_outcome_to_protocol_result_with_trace(&payload, outcome);
11587
11588        assert_protocol_result_equivalent_except_error_details(&plain, &traced);
11589
11590        let plain_error = plain.error.expect("plain conversion should include error");
11591        assert!(
11592            plain_error.details.is_none(),
11593            "plain conversion should not inject trace details"
11594        );
11595        let traced_error = traced.error.expect("trace conversion should include error");
11596        let details = traced_error
11597            .details
11598            .expect("trace conversion should include structured details");
11599        assert_eq!(
11600            details["dispatcherDecisionTrace"]["fallbackReason"],
11601            serde_json::json!("schema_validation_failed")
11602        );
11603        assert_eq!(
11604            details["schemaDiff"]["observedParamKeys"],
11605            serde_json::json!(["alpha", "zeta"])
11606        );
11607        assert_eq!(
11608            details["extensionInput"]["callId"],
11609            serde_json::json!("call-trace-error")
11610        );
11611        assert_eq!(
11612            details["extensionOutput"]["code"],
11613            serde_json::json!("invalid_request")
11614        );
11615    }
11616
11617    #[test]
11618    fn hostcall_outcome_to_protocol_result_with_trace_normalizes_invalid_request_taxonomy() {
11619        let mut known_method_payload = test_protocol_payload("call-trace-error-known");
11620        known_method_payload.method = " TOOL ".to_string();
11621        let known_method_result = hostcall_outcome_to_protocol_result_with_trace(
11622            &known_method_payload,
11623            HostcallOutcome::Error {
11624                code: "  INVALID_REQUEST ".to_string(),
11625                message: "bad request".to_string(),
11626            },
11627        );
11628        let known_method_error = known_method_result.error.expect("expected error");
11629        assert_eq!(known_method_error.code, HostCallErrorCode::InvalidRequest);
11630        let known_details = known_method_error.details.expect("expected details");
11631        assert_eq!(
11632            known_details["dispatcherDecisionTrace"]["fallbackReason"],
11633            serde_json::json!("schema_validation_failed")
11634        );
11635
11636        let mut unknown_method_payload = test_protocol_payload("call-trace-error-unknown");
11637        unknown_method_payload.method = "custom_method".to_string();
11638        let unknown_method_result = hostcall_outcome_to_protocol_result_with_trace(
11639            &unknown_method_payload,
11640            HostcallOutcome::Error {
11641                code: "  INVALID_REQUEST ".to_string(),
11642                message: "bad request".to_string(),
11643            },
11644        );
11645        let unknown_method_error = unknown_method_result.error.expect("expected error");
11646        assert_eq!(unknown_method_error.code, HostCallErrorCode::InvalidRequest);
11647        let unknown_details = unknown_method_error.details.expect("expected details");
11648        assert_eq!(
11649            unknown_details["dispatcherDecisionTrace"]["fallbackReason"],
11650            serde_json::json!("unsupported_method_fallback")
11651        );
11652    }
11653
11654    #[test]
11655    fn hostcall_outcome_to_protocol_result_with_trace_normalizes_tool_error_taxonomy() {
11656        let mut payload = test_protocol_payload("call-trace-error-tool");
11657        payload.method = "tool".to_string();
11658        let result = hostcall_outcome_to_protocol_result_with_trace(
11659            &payload,
11660            HostcallOutcome::Error {
11661                code: "  TOOL_ERROR ".to_string(),
11662                message: "handler exploded".to_string(),
11663            },
11664        );
11665
11666        let error = result.error.expect("expected error");
11667        assert_eq!(error.code, HostCallErrorCode::Io);
11668        let details = error.details.expect("expected details");
11669        assert_eq!(
11670            details["dispatcherDecisionTrace"]["fallbackReason"],
11671            serde_json::json!("handler_error")
11672        );
11673        assert_eq!(
11674            details["extensionOutput"]["code"],
11675            serde_json::json!("  TOOL_ERROR ")
11676        );
11677    }
11678
11679    #[test]
11680    fn hostcall_outcome_to_protocol_result_with_trace_normalizes_timeout_taxonomy() {
11681        let mut payload = test_protocol_payload("call-trace-error-timeout");
11682        payload.method = "exec".to_string();
11683        let result = hostcall_outcome_to_protocol_result_with_trace(
11684            &payload,
11685            HostcallOutcome::Error {
11686                code: "  TimeOut  ".to_string(),
11687                message: "handler timed out".to_string(),
11688            },
11689        );
11690
11691        let error = result.error.expect("expected error");
11692        assert_eq!(error.code, HostCallErrorCode::Timeout);
11693        let details = error.details.expect("expected details");
11694        assert_eq!(
11695            details["dispatcherDecisionTrace"]["fallbackReason"],
11696            serde_json::json!("handler_timeout")
11697        );
11698        assert_eq!(
11699            details["extensionOutput"]["code"],
11700            serde_json::json!("  TimeOut  ")
11701        );
11702    }
11703
11704    #[test]
11705    fn hostcall_outcome_to_protocol_result_with_trace_normalizes_denied_taxonomy() {
11706        let mut payload = test_protocol_payload("call-trace-error-denied");
11707        payload.method = "session".to_string();
11708        let result = hostcall_outcome_to_protocol_result_with_trace(
11709            &payload,
11710            HostcallOutcome::Error {
11711                code: "  DeNied ".to_string(),
11712                message: "blocked by policy".to_string(),
11713            },
11714        );
11715
11716        let error = result.error.expect("expected error");
11717        assert_eq!(error.code, HostCallErrorCode::Denied);
11718        let details = error.details.expect("expected details");
11719        assert_eq!(
11720            details["dispatcherDecisionTrace"]["fallbackReason"],
11721            serde_json::json!("policy_denied")
11722        );
11723        assert_eq!(
11724            details["extensionOutput"]["code"],
11725            serde_json::json!("  DeNied ")
11726        );
11727    }
11728
11729    #[test]
11730    fn hostcall_outcome_to_protocol_result_with_trace_normalizes_unknown_code_to_internal_taxonomy()
11731    {
11732        let mut payload = test_protocol_payload("call-trace-error-unknown-code");
11733        payload.method = "tool".to_string();
11734        let result = hostcall_outcome_to_protocol_result_with_trace(
11735            &payload,
11736            HostcallOutcome::Error {
11737                code: "  SOME_NEW_CODE ".to_string(),
11738                message: "unexpected runtime state".to_string(),
11739            },
11740        );
11741
11742        let error = result.error.expect("expected error");
11743        assert_eq!(error.code, HostCallErrorCode::Internal);
11744        let details = error.details.expect("expected details");
11745        assert_eq!(
11746            details["dispatcherDecisionTrace"]["fallbackReason"],
11747            serde_json::json!("runtime_internal_error")
11748        );
11749        assert_eq!(
11750            details["extensionOutput"]["code"],
11751            serde_json::json!("  SOME_NEW_CODE ")
11752        );
11753    }
11754
11755    #[test]
11756    fn hostcall_code_to_str_roundtrips_all_variants() {
11757        use crate::connectors::HostCallErrorCode;
11758        assert_eq!(hostcall_code_to_str(HostCallErrorCode::Timeout), "timeout");
11759        assert_eq!(hostcall_code_to_str(HostCallErrorCode::Denied), "denied");
11760        assert_eq!(hostcall_code_to_str(HostCallErrorCode::Io), "io");
11761        assert_eq!(
11762            hostcall_code_to_str(HostCallErrorCode::InvalidRequest),
11763            "invalid_request"
11764        );
11765        assert_eq!(
11766            hostcall_code_to_str(HostCallErrorCode::Internal),
11767            "internal"
11768        );
11769    }
11770
11771    // -----------------------------------------------------------------------
11772    // Protocol dispatch for all method types
11773    // -----------------------------------------------------------------------
11774
11775    #[test]
11776    fn protocol_dispatch_tool_success() {
11777        futures::executor::block_on(async {
11778            let temp_dir = tempfile::tempdir().expect("tempdir");
11779            std::fs::write(temp_dir.path().join("file.txt"), "protocol test content")
11780                .expect("write");
11781
11782            let runtime = Rc::new(
11783                PiJsRuntime::with_clock(DeterministicClock::new(0))
11784                    .await
11785                    .expect("runtime"),
11786            );
11787            let dispatcher = ExtensionDispatcher::new_with_policy(
11788                Rc::clone(&runtime),
11789                Arc::new(ToolRegistry::new(&["read"], temp_dir.path(), None)),
11790                Arc::new(HttpConnector::with_defaults()),
11791                Arc::new(NullSession),
11792                Arc::new(NullUiHandler),
11793                temp_dir.path().to_path_buf(),
11794                ExtensionPolicy::from_profile(PolicyProfile::Permissive),
11795            );
11796
11797            let message = ExtensionMessage {
11798                id: "msg-tool-proto".to_string(),
11799                version: PROTOCOL_VERSION.to_string(),
11800                body: ExtensionBody::HostCall(HostCallPayload {
11801                    call_id: "call-tool-proto".to_string(),
11802                    capability: "read".to_string(),
11803                    method: "tool".to_string(),
11804                    params: serde_json::json!({ "name": "read", "input": { "path": "file.txt" } }),
11805                    timeout_ms: None,
11806                    cancel_token: None,
11807                    context: None,
11808                }),
11809            };
11810
11811            let response = dispatcher
11812                .dispatch_protocol_message(message)
11813                .await
11814                .expect("protocol tool dispatch");
11815
11816            match response.body {
11817                ExtensionBody::HostResult(result) => {
11818                    assert!(!result.is_error, "expected success: {result:?}");
11819                    assert!(result.output.is_object());
11820                }
11821                other => panic!(),
11822            }
11823        });
11824    }
11825
11826    #[test]
11827    fn protocol_dispatch_tool_missing_name_returns_invalid_request() {
11828        futures::executor::block_on(async {
11829            let runtime = Rc::new(
11830                PiJsRuntime::with_clock(DeterministicClock::new(0))
11831                    .await
11832                    .expect("runtime"),
11833            );
11834            let dispatcher = build_dispatcher(Rc::clone(&runtime));
11835
11836            let message = ExtensionMessage {
11837                id: "msg-tool-noname".to_string(),
11838                version: PROTOCOL_VERSION.to_string(),
11839                body: ExtensionBody::HostCall(HostCallPayload {
11840                    call_id: "call-tool-noname".to_string(),
11841                    capability: "tool".to_string(),
11842                    method: "tool".to_string(),
11843                    params: serde_json::json!({ "input": {} }),
11844                    timeout_ms: None,
11845                    cancel_token: None,
11846                    context: None,
11847                }),
11848            };
11849
11850            let response = dispatcher
11851                .dispatch_protocol_message(message)
11852                .await
11853                .expect("protocol dispatch");
11854
11855            match response.body {
11856                ExtensionBody::HostResult(result) => {
11857                    assert!(result.is_error);
11858                    let error = result.error.expect("error");
11859                    assert_eq!(error.code, HostCallErrorCode::InvalidRequest);
11860                    assert!(
11861                        error.message.contains("method") || error.message.contains("tool"),
11862                        "error should mention 'method' or 'tool': {}",
11863                        error.message
11864                    );
11865                }
11866                other => panic!(),
11867            }
11868        });
11869    }
11870
11871    #[test]
11872    fn protocol_dispatch_tool_empty_name_returns_invalid_request() {
11873        futures::executor::block_on(async {
11874            let runtime = Rc::new(
11875                PiJsRuntime::with_clock(DeterministicClock::new(0))
11876                    .await
11877                    .expect("runtime"),
11878            );
11879            let dispatcher = build_dispatcher(Rc::clone(&runtime));
11880
11881            let message = ExtensionMessage {
11882                id: "msg-tool-empty".to_string(),
11883                version: PROTOCOL_VERSION.to_string(),
11884                body: ExtensionBody::HostCall(HostCallPayload {
11885                    call_id: "call-tool-empty".to_string(),
11886                    capability: "tool".to_string(),
11887                    method: "tool".to_string(),
11888                    params: serde_json::json!({ "name": "", "input": {} }),
11889                    timeout_ms: None,
11890                    cancel_token: None,
11891                    context: None,
11892                }),
11893            };
11894
11895            let response = dispatcher
11896                .dispatch_protocol_message(message)
11897                .await
11898                .expect("protocol dispatch");
11899
11900            match response.body {
11901                ExtensionBody::HostResult(result) => {
11902                    assert!(result.is_error);
11903                    let error = result.error.expect("error");
11904                    assert_eq!(error.code, HostCallErrorCode::InvalidRequest);
11905                }
11906                other => panic!(),
11907            }
11908        });
11909    }
11910
11911    #[test]
11912    fn protocol_dispatch_http_success() {
11913        futures::executor::block_on(async {
11914            let addr = spawn_http_server("protocol http ok");
11915
11916            let runtime = Rc::new(
11917                PiJsRuntime::with_clock(DeterministicClock::new(0))
11918                    .await
11919                    .expect("runtime"),
11920            );
11921            let dispatcher = ExtensionDispatcher::new_with_policy(
11922                Rc::clone(&runtime),
11923                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
11924                Arc::new(HttpConnector::new(HttpConnectorConfig {
11925                    default_timeout_ms: 5000,
11926                    require_tls: false,
11927                    ..HttpConnectorConfig::default()
11928                })),
11929                Arc::new(NullSession),
11930                Arc::new(NullUiHandler),
11931                PathBuf::from("."),
11932                ExtensionPolicy::from_profile(PolicyProfile::Permissive),
11933            );
11934
11935            let message = ExtensionMessage {
11936                id: "msg-http-proto".to_string(),
11937                version: PROTOCOL_VERSION.to_string(),
11938                body: ExtensionBody::HostCall(HostCallPayload {
11939                    call_id: "call-http-proto".to_string(),
11940                    capability: "http".to_string(),
11941                    method: "http".to_string(),
11942                    params: serde_json::json!({
11943                        "url": format!("http://{addr}/test"),
11944                        "method": "GET",
11945                    }),
11946                    timeout_ms: None,
11947                    cancel_token: None,
11948                    context: None,
11949                }),
11950            };
11951
11952            let response = dispatcher
11953                .dispatch_protocol_message(message)
11954                .await
11955                .expect("protocol http dispatch");
11956
11957            match response.body {
11958                ExtensionBody::HostResult(result) => {
11959                    assert!(!result.is_error, "expected success: {result:?}");
11960                }
11961                other => panic!(),
11962            }
11963        });
11964    }
11965
11966    #[test]
11967    fn protocol_dispatch_ui_success() {
11968        futures::executor::block_on(async {
11969            let runtime = Rc::new(
11970                PiJsRuntime::with_clock(DeterministicClock::new(0))
11971                    .await
11972                    .expect("runtime"),
11973            );
11974            let dispatcher = ExtensionDispatcher::new_with_policy(
11975                Rc::clone(&runtime),
11976                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
11977                Arc::new(HttpConnector::with_defaults()),
11978                Arc::new(NullSession),
11979                Arc::new(NullUiHandler),
11980                PathBuf::from("."),
11981                ExtensionPolicy::from_profile(PolicyProfile::Permissive),
11982            );
11983
11984            let message = ExtensionMessage {
11985                id: "msg-ui-proto".to_string(),
11986                version: PROTOCOL_VERSION.to_string(),
11987                body: ExtensionBody::HostCall(HostCallPayload {
11988                    call_id: "call-ui-proto".to_string(),
11989                    capability: "ui".to_string(),
11990                    method: "ui".to_string(),
11991                    params: serde_json::json!({ "op": "notification", "message": "test" }),
11992                    timeout_ms: None,
11993                    cancel_token: None,
11994                    context: None,
11995                }),
11996            };
11997
11998            let response = dispatcher
11999                .dispatch_protocol_message(message)
12000                .await
12001                .expect("protocol ui dispatch");
12002
12003            match response.body {
12004                ExtensionBody::HostResult(result) => {
12005                    assert!(!result.is_error, "expected success: {result:?}");
12006                }
12007                other => panic!(),
12008            }
12009        });
12010    }
12011
12012    #[test]
12013    fn protocol_dispatch_ui_missing_op_returns_error() {
12014        futures::executor::block_on(async {
12015            let runtime = Rc::new(
12016                PiJsRuntime::with_clock(DeterministicClock::new(0))
12017                    .await
12018                    .expect("runtime"),
12019            );
12020            let dispatcher = build_dispatcher(Rc::clone(&runtime));
12021
12022            let message = ExtensionMessage {
12023                id: "msg-ui-noop".to_string(),
12024                version: PROTOCOL_VERSION.to_string(),
12025                body: ExtensionBody::HostCall(HostCallPayload {
12026                    call_id: "call-ui-noop".to_string(),
12027                    capability: "ui".to_string(),
12028                    method: "ui".to_string(),
12029                    params: serde_json::json!({ "message": "test" }),
12030                    timeout_ms: None,
12031                    cancel_token: None,
12032                    context: None,
12033                }),
12034            };
12035
12036            let response = dispatcher
12037                .dispatch_protocol_message(message)
12038                .await
12039                .expect("protocol dispatch");
12040
12041            match response.body {
12042                ExtensionBody::HostResult(result) => {
12043                    assert!(result.is_error);
12044                    let error = result.error.expect("error");
12045                    assert_eq!(error.code, HostCallErrorCode::InvalidRequest);
12046                    assert!(
12047                        error.message.contains("op"),
12048                        "error should mention 'op': {}",
12049                        error.message
12050                    );
12051                }
12052                other => panic!(),
12053            }
12054        });
12055    }
12056
12057    #[test]
12058    fn protocol_dispatch_events_missing_op_returns_error() {
12059        futures::executor::block_on(async {
12060            let runtime = Rc::new(
12061                PiJsRuntime::with_clock(DeterministicClock::new(0))
12062                    .await
12063                    .expect("runtime"),
12064            );
12065            let dispatcher = build_dispatcher(Rc::clone(&runtime));
12066
12067            let message = ExtensionMessage {
12068                id: "msg-events-noop".to_string(),
12069                version: PROTOCOL_VERSION.to_string(),
12070                body: ExtensionBody::HostCall(HostCallPayload {
12071                    call_id: "call-events-noop".to_string(),
12072                    capability: "events".to_string(),
12073                    method: "events".to_string(),
12074                    params: serde_json::json!({ "data": {} }),
12075                    timeout_ms: None,
12076                    cancel_token: None,
12077                    context: None,
12078                }),
12079            };
12080
12081            let response = dispatcher
12082                .dispatch_protocol_message(message)
12083                .await
12084                .expect("protocol dispatch");
12085
12086            match response.body {
12087                ExtensionBody::HostResult(result) => {
12088                    assert!(result.is_error);
12089                    let error = result.error.expect("error");
12090                    assert_eq!(error.code, HostCallErrorCode::InvalidRequest);
12091                    assert!(
12092                        error.message.contains("op"),
12093                        "error should mention 'op': {}",
12094                        error.message
12095                    );
12096                }
12097                other => panic!(),
12098            }
12099        });
12100    }
12101
12102    #[test]
12103    fn protocol_dispatch_log_returns_success() {
12104        futures::executor::block_on(async {
12105            let runtime = Rc::new(
12106                PiJsRuntime::with_clock(DeterministicClock::new(0))
12107                    .await
12108                    .expect("runtime"),
12109            );
12110            let dispatcher = build_dispatcher(Rc::clone(&runtime));
12111
12112            let message = ExtensionMessage {
12113                id: "msg-log-proto".to_string(),
12114                version: PROTOCOL_VERSION.to_string(),
12115                body: ExtensionBody::HostCall(HostCallPayload {
12116                    call_id: "call-log-proto".to_string(),
12117                    capability: "log".to_string(),
12118                    method: "log".to_string(),
12119                    params: serde_json::json!({ "message": "test log" }),
12120                    timeout_ms: None,
12121                    cancel_token: None,
12122                    context: None,
12123                }),
12124            };
12125
12126            let response = dispatcher
12127                .dispatch_protocol_message(message)
12128                .await
12129                .expect("protocol log dispatch");
12130
12131            match response.body {
12132                ExtensionBody::HostResult(result) => {
12133                    assert!(!result.is_error, "log dispatch should succeed: {result:?}");
12134                }
12135                other => panic!(),
12136            }
12137        });
12138    }
12139
12140    fn regime_signal(
12141        queue_depth: f64,
12142        service_time_us: f64,
12143        opcode_entropy: f64,
12144        llc_miss_rate: f64,
12145    ) -> RegimeSignal {
12146        RegimeSignal {
12147            queue_depth,
12148            service_time_us,
12149            opcode_entropy,
12150            llc_miss_rate,
12151        }
12152    }
12153
12154    fn drive_detector_to_interleaved(detector: &mut RegimeShiftDetector) {
12155        for _ in 0..64 {
12156            let _ = detector.observe(regime_signal(1.0, 600.0, 0.8, 0.02));
12157        }
12158        for _ in 0..48 {
12159            let observation = detector.observe(regime_signal(40.0, 14_000.0, 2.6, 0.92));
12160            if observation.transition == Some(RegimeTransition::EnterInterleavedBatching) {
12161                break;
12162            }
12163        }
12164    }
12165
12166    #[test]
12167    fn regime_detector_switches_to_interleaved_on_sustained_upshift() {
12168        let mut detector = RegimeShiftDetector::default();
12169        let mut switched = false;
12170
12171        for _ in 0..64 {
12172            let _ = detector.observe(regime_signal(1.0, 700.0, 0.9, 0.03));
12173        }
12174        for _ in 0..64 {
12175            let observation = detector.observe(regime_signal(42.0, 16_000.0, 2.8, 0.95));
12176            if observation.transition == Some(RegimeTransition::EnterInterleavedBatching) {
12177                switched = true;
12178                break;
12179            }
12180        }
12181
12182        assert!(
12183            switched,
12184            "detector should switch on sustained high-contention shift"
12185        );
12186        assert_eq!(
12187            detector.current_mode(),
12188            RegimeAdaptationMode::InterleavedBatching
12189        );
12190    }
12191
12192    #[test]
12193    fn regime_detector_avoids_false_positives_on_stationary_noise() {
12194        let mut detector = RegimeShiftDetector::default();
12195        let mut transitions = 0_usize;
12196
12197        for idx in 0..320 {
12198            let jitter = match idx % 5 {
12199                0 => -70.0,
12200                1 => -20.0,
12201                2 => 0.0,
12202                3 => 35.0,
12203                _ => 80.0,
12204            };
12205            let queue_depth = if idx % 3 == 0 { 2.0 } else { 1.0 };
12206            let entropy = if idx % 7 == 0 { 1.2 } else { 1.0 };
12207            let observation =
12208                detector.observe(regime_signal(queue_depth, 900.0 + jitter, entropy, 0.06));
12209            if observation.transition.is_some() {
12210                transitions = transitions.saturating_add(1);
12211            }
12212        }
12213
12214        assert_eq!(
12215            transitions, 0,
12216            "stationary noise should not trigger transitions"
12217        );
12218        assert_eq!(
12219            detector.current_mode(),
12220            RegimeAdaptationMode::SequentialFastPath
12221        );
12222    }
12223
12224    #[test]
12225    fn regime_detector_hysteresis_limits_thrash() {
12226        let mut detector = RegimeShiftDetector::default();
12227        drive_detector_to_interleaved(&mut detector);
12228        assert_eq!(
12229            detector.current_mode(),
12230            RegimeAdaptationMode::InterleavedBatching
12231        );
12232
12233        let mut transitions = 0_usize;
12234        for idx in 0..200 {
12235            let signal = if idx % 2 == 0 {
12236                regime_signal(36.0, 12_500.0, 2.4, 0.88)
12237            } else {
12238                regime_signal(5.0, 2_200.0, 1.1, 0.18)
12239            };
12240            let observation = detector.observe(signal);
12241            if observation.transition.is_some() {
12242                transitions = transitions.saturating_add(1);
12243            }
12244        }
12245
12246        assert!(
12247            transitions <= 5,
12248            "hysteresis/cooldown should prevent oscillation: observed {transitions} transitions"
12249        );
12250    }
12251
12252    #[test]
12253    fn regime_detector_fallbacks_when_workload_cools() {
12254        let mut detector = RegimeShiftDetector::default();
12255        drive_detector_to_interleaved(&mut detector);
12256        assert_eq!(
12257            detector.current_mode(),
12258            RegimeAdaptationMode::InterleavedBatching
12259        );
12260
12261        let mut fallback_triggered = false;
12262        let mut returned_to_sequential = false;
12263        for _ in 0..40 {
12264            let observation = detector.observe(regime_signal(0.0, 450.0, 0.2, 0.0));
12265            if observation.fallback_triggered {
12266                fallback_triggered = true;
12267            }
12268            if observation.transition == Some(RegimeTransition::ReturnToSequentialFastPath) {
12269                returned_to_sequential = true;
12270            }
12271        }
12272
12273        assert!(
12274            fallback_triggered,
12275            "low queue/latency should trigger conservative fallback"
12276        );
12277        assert!(
12278            returned_to_sequential,
12279            "fallback should report an explicit transition"
12280        );
12281        assert_eq!(
12282            detector.current_mode(),
12283            RegimeAdaptationMode::SequentialFastPath
12284        );
12285    }
12286
12287    #[test]
12288    fn rollout_gate_blocks_cherry_picked_high_contention_claims() {
12289        let mut detector = RegimeShiftDetector::default();
12290        let mut saw_block = false;
12291        let mut switched = false;
12292
12293        for _ in 0..160 {
12294            let observation = detector.observe(regime_signal(46.0, 17_500.0, 3.0, 0.95));
12295            if observation.rollout_blocked_cherry_picked {
12296                saw_block = true;
12297            }
12298            if observation.transition == Some(RegimeTransition::EnterInterleavedBatching) {
12299                switched = true;
12300            }
12301        }
12302
12303        assert!(saw_block, "gate should surface cherry-pick blocking signal");
12304        assert!(!switched, "high-only stream must not promote rollout");
12305        assert_eq!(
12306            detector.current_mode(),
12307            RegimeAdaptationMode::SequentialFastPath
12308        );
12309    }
12310
12311    #[test]
12312    fn rollout_gate_promotes_after_stratified_evidence_reaches_threshold() {
12313        let mut detector = RegimeShiftDetector::default();
12314        let mut promoted = false;
12315
12316        for _ in 0..80 {
12317            let _ = detector.observe(regime_signal(1.0, 700.0, 0.9, 0.03));
12318        }
12319        for _ in 0..96 {
12320            let observation = detector.observe(regime_signal(42.0, 16_000.0, 2.8, 0.95));
12321            if observation.transition == Some(RegimeTransition::EnterInterleavedBatching) {
12322                promoted = true;
12323                assert_eq!(
12324                    observation.rollout_action,
12325                    RolloutGateAction::PromoteInterleaved
12326                );
12327                assert!(
12328                    observation.rollout_promote_e_process >= observation.rollout_evidence_threshold
12329                );
12330                assert!(observation.rollout_coverage_ready);
12331                assert!(
12332                    observation.rollout_expected_loss.promote
12333                        < observation.rollout_expected_loss.hold
12334                );
12335                break;
12336            }
12337        }
12338
12339        assert!(
12340            promoted,
12341            "stratified stream should promote interleaved batching"
12342        );
12343        assert_eq!(
12344            detector.current_mode(),
12345            RegimeAdaptationMode::InterleavedBatching
12346        );
12347    }
12348
12349    #[test]
12350    fn rollout_gate_rolls_back_after_stratified_regression_evidence() {
12351        let mut detector = RegimeShiftDetector::default();
12352        drive_detector_to_interleaved(&mut detector);
12353        assert_eq!(
12354            detector.current_mode(),
12355            RegimeAdaptationMode::InterleavedBatching
12356        );
12357
12358        let mut rolled_back = false;
12359        for _ in 0..320 {
12360            let observation = detector.observe(regime_signal(1.4, 1_500.0, 0.6, 0.02));
12361            if observation.transition == Some(RegimeTransition::ReturnToSequentialFastPath) {
12362                rolled_back = true;
12363                assert_eq!(
12364                    observation.rollout_action,
12365                    RolloutGateAction::RollbackSequential
12366                );
12367                assert!(
12368                    observation.rollout_rollback_e_process
12369                        >= observation.rollout_evidence_threshold
12370                );
12371                assert!(observation.rollout_coverage_ready);
12372                assert!(
12373                    observation.rollout_expected_loss.rollback
12374                        < observation.rollout_expected_loss.hold
12375                );
12376                break;
12377            }
12378        }
12379
12380        assert!(
12381            rolled_back,
12382            "low-contention regression stream should trigger rollout rollback"
12383        );
12384        assert_eq!(
12385            detector.current_mode(),
12386            RegimeAdaptationMode::SequentialFastPath
12387        );
12388    }
12389
12390    #[test]
12391    fn dual_exec_sampling_is_deterministic_for_same_request() {
12392        let request = HostcallRequest {
12393            call_id: "sample-deterministic".to_string(),
12394            kind: HostcallKind::Session {
12395                op: "get_state".to_string(),
12396            },
12397            payload: serde_json::json!({}),
12398            trace_id: 77,
12399            extension_id: Some("ext.det".to_string()),
12400        };
12401        let first = should_sample_shadow_dual_exec(&request, 100_000);
12402        for _ in 0..16 {
12403            assert_eq!(should_sample_shadow_dual_exec(&request, 100_000), first);
12404        }
12405    }
12406
12407    #[test]
12408    fn dual_exec_sampling_respects_zero_and_full_scale_boundaries() {
12409        let request = HostcallRequest {
12410            call_id: "sample-boundary".to_string(),
12411            kind: HostcallKind::Session {
12412                op: "get_state".to_string(),
12413            },
12414            payload: serde_json::json!({}),
12415            trace_id: 91,
12416            extension_id: Some("ext.boundary".to_string()),
12417        };
12418
12419        assert!(!should_sample_shadow_dual_exec(&request, 0));
12420        assert!(should_sample_shadow_dual_exec(
12421            &request,
12422            DUAL_EXEC_SAMPLE_MODULUS_PPM
12423        ));
12424        assert!(should_sample_shadow_dual_exec(
12425            &request,
12426            DUAL_EXEC_SAMPLE_MODULUS_PPM.saturating_add(1)
12427        ));
12428    }
12429
12430    #[test]
12431    fn normalized_shadow_op_is_deterministic_across_format_variants() {
12432        assert_eq!(normalized_shadow_op(" get__state "), "getstate");
12433        assert_eq!(normalized_shadow_op("GET_STATE"), "getstate");
12434        assert_eq!(normalized_shadow_op("GeT_sTaTe"), "getstate");
12435        assert_eq!(normalized_shadow_op("list_flags"), "listflags");
12436    }
12437
12438    #[test]
12439    fn shadow_safe_classification_accepts_normalized_read_only_ops() {
12440        let session_request = HostcallRequest {
12441            call_id: "shadow-safe-session".to_string(),
12442            kind: HostcallKind::Session {
12443                op: "  GET__MESSAGES ".to_string(),
12444            },
12445            payload: serde_json::json!({}),
12446            trace_id: 5,
12447            extension_id: Some("ext.shadow.safe".to_string()),
12448        };
12449        let events_request = HostcallRequest {
12450            call_id: "shadow-safe-events".to_string(),
12451            kind: HostcallKind::Events {
12452                op: " list_flags ".to_string(),
12453            },
12454            payload: serde_json::json!({}),
12455            trace_id: 6,
12456            extension_id: Some("ext.shadow.safe".to_string()),
12457        };
12458        let tool_request = HostcallRequest {
12459            call_id: "shadow-safe-tool".to_string(),
12460            kind: HostcallKind::Tool {
12461                name: " read ".to_string(),
12462            },
12463            payload: serde_json::json!({}),
12464            trace_id: 7,
12465            extension_id: Some("ext.shadow.safe".to_string()),
12466        };
12467
12468        assert!(is_shadow_safe_request(&session_request));
12469        assert!(is_shadow_safe_request(&events_request));
12470        assert!(is_shadow_safe_request(&tool_request));
12471    }
12472
12473    #[test]
12474    fn shadow_safe_classification_rejects_mutating_and_unsafe_kinds() {
12475        let requests = [
12476            (
12477                "session mutate",
12478                HostcallRequest {
12479                    call_id: "shadow-unsafe-session".to_string(),
12480                    kind: HostcallKind::Session {
12481                        op: "append_message".to_string(),
12482                    },
12483                    payload: serde_json::json!({}),
12484                    trace_id: 11,
12485                    extension_id: Some("ext.shadow.unsafe".to_string()),
12486                },
12487            ),
12488            (
12489                "events mutate",
12490                HostcallRequest {
12491                    call_id: "shadow-unsafe-events".to_string(),
12492                    kind: HostcallKind::Events {
12493                        op: "set_flag".to_string(),
12494                    },
12495                    payload: serde_json::json!({}),
12496                    trace_id: 12,
12497                    extension_id: Some("ext.shadow.unsafe".to_string()),
12498                },
12499            ),
12500            (
12501                "tool mutate",
12502                HostcallRequest {
12503                    call_id: "shadow-unsafe-tool".to_string(),
12504                    kind: HostcallKind::Tool {
12505                        name: "write".to_string(),
12506                    },
12507                    payload: serde_json::json!({}),
12508                    trace_id: 13,
12509                    extension_id: Some("ext.shadow.unsafe".to_string()),
12510                },
12511            ),
12512            (
12513                "exec",
12514                HostcallRequest {
12515                    call_id: "shadow-unsafe-exec".to_string(),
12516                    kind: HostcallKind::Exec {
12517                        cmd: "echo nope".to_string(),
12518                    },
12519                    payload: serde_json::json!({}),
12520                    trace_id: 14,
12521                    extension_id: Some("ext.shadow.unsafe".to_string()),
12522                },
12523            ),
12524            (
12525                "http",
12526                HostcallRequest {
12527                    call_id: "shadow-unsafe-http".to_string(),
12528                    kind: HostcallKind::Http,
12529                    payload: serde_json::json!({}),
12530                    trace_id: 15,
12531                    extension_id: Some("ext.shadow.unsafe".to_string()),
12532                },
12533            ),
12534            (
12535                "ui",
12536                HostcallRequest {
12537                    call_id: "shadow-unsafe-ui".to_string(),
12538                    kind: HostcallKind::Ui {
12539                        op: "prompt".to_string(),
12540                    },
12541                    payload: serde_json::json!({}),
12542                    trace_id: 16,
12543                    extension_id: Some("ext.shadow.unsafe".to_string()),
12544                },
12545            ),
12546            (
12547                "log",
12548                HostcallRequest {
12549                    call_id: "shadow-unsafe-log".to_string(),
12550                    kind: HostcallKind::Log,
12551                    payload: serde_json::json!({}),
12552                    trace_id: 17,
12553                    extension_id: Some("ext.shadow.unsafe".to_string()),
12554                },
12555            ),
12556        ];
12557
12558        for (case, request) in &requests {
12559            assert!(
12560                !is_shadow_safe_request(request),
12561                "expected non-shadow-safe classification for {case}"
12562            );
12563        }
12564    }
12565
12566    #[test]
12567    fn dual_exec_diff_engine_detects_success_output_mismatch() {
12568        let fast = HostcallOutcome::Success(serde_json::json!({ "value": 1 }));
12569        let compat = HostcallOutcome::Success(serde_json::json!({ "value": 2 }));
12570        let diff = diff_hostcall_outcomes(&fast, &compat).expect("expected diff");
12571        assert_eq!(diff.reason, "success_output_mismatch");
12572        assert_ne!(diff.fast_fingerprint, diff.compat_fingerprint);
12573    }
12574
12575    #[test]
12576    fn dual_exec_forensic_bundle_includes_trace_lane_diff_and_rollback_fields() {
12577        let request = HostcallRequest {
12578            call_id: "forensic-1".to_string(),
12579            kind: HostcallKind::Session {
12580                op: "get_state".to_string(),
12581            },
12582            payload: serde_json::json!({ "op": "get_state" }),
12583            trace_id: 9,
12584            extension_id: Some("ext.forensic".to_string()),
12585        };
12586        let diff = DualExecOutcomeDiff {
12587            reason: "success_output_mismatch",
12588            fast_fingerprint: "success:aaa".to_string(),
12589            compat_fingerprint: "success:bbb".to_string(),
12590        };
12591        let bundle = dual_exec_forensic_bundle(
12592            &request,
12593            &diff,
12594            Some("forced_compat_budget_controller"),
12595            42.0,
12596        );
12597        assert_eq!(
12598            bundle["call_trace"]["call_id"],
12599            Value::String("forensic-1".to_string())
12600        );
12601        assert_eq!(
12602            bundle["lane_decision"]["fast_lane"],
12603            Value::String("fast".to_string())
12604        );
12605        assert_eq!(
12606            bundle["lane_decision"]["compat_lane"],
12607            Value::String("compat_shadow".to_string())
12608        );
12609        assert_eq!(
12610            bundle["diff"]["reason"],
12611            Value::String("success_output_mismatch".to_string())
12612        );
12613        assert_eq!(
12614            bundle["rollback"]["reason"],
12615            Value::String("forced_compat_budget_controller".to_string())
12616        );
12617    }
12618
12619    #[test]
12620    #[allow(clippy::too_many_lines)]
12621    fn dual_exec_divergence_auto_triggers_rollback_kill_switch_state() {
12622        futures::executor::block_on(async {
12623            struct DivergentReadSession {
12624                counter: Arc<Mutex<u64>>,
12625            }
12626
12627            #[async_trait]
12628            impl ExtensionSession for DivergentReadSession {
12629                async fn get_state(&self) -> Value {
12630                    let mut guard = self
12631                        .counter
12632                        .lock()
12633                        .unwrap_or_else(std::sync::PoisonError::into_inner);
12634                    let value = *guard;
12635                    *guard = guard.saturating_add(1);
12636                    drop(guard);
12637                    serde_json::json!({ "seq": value })
12638                }
12639
12640                async fn get_messages(&self) -> Vec<SessionMessage> {
12641                    Vec::new()
12642                }
12643
12644                async fn get_entries(&self) -> Vec<Value> {
12645                    Vec::new()
12646                }
12647
12648                async fn get_branch(&self) -> Vec<Value> {
12649                    Vec::new()
12650                }
12651
12652                async fn set_name(&self, _name: String) -> Result<()> {
12653                    Ok(())
12654                }
12655
12656                async fn append_message(&self, _message: SessionMessage) -> Result<()> {
12657                    Ok(())
12658                }
12659
12660                async fn append_custom_entry(
12661                    &self,
12662                    _custom_type: String,
12663                    _data: Option<Value>,
12664                ) -> Result<()> {
12665                    Ok(())
12666                }
12667
12668                async fn set_model(&self, _provider: String, _model_id: String) -> Result<()> {
12669                    Ok(())
12670                }
12671
12672                async fn get_model(&self) -> (Option<String>, Option<String>) {
12673                    (None, None)
12674                }
12675
12676                async fn set_thinking_level(&self, _level: String) -> Result<()> {
12677                    Ok(())
12678                }
12679
12680                async fn get_thinking_level(&self) -> Option<String> {
12681                    None
12682                }
12683
12684                async fn set_label(
12685                    &self,
12686                    _target_id: String,
12687                    _label: Option<String>,
12688                ) -> Result<()> {
12689                    Ok(())
12690                }
12691            }
12692
12693            let runtime = Rc::new(
12694                PiJsRuntime::with_clock(DeterministicClock::new(0))
12695                    .await
12696                    .expect("runtime"),
12697            );
12698            let session = Arc::new(DivergentReadSession {
12699                counter: Arc::new(Mutex::new(0)),
12700            });
12701            let oracle_config = DualExecOracleConfig {
12702                sample_ppm: DUAL_EXEC_SAMPLE_MODULUS_PPM,
12703                divergence_window: 4,
12704                divergence_budget: 2,
12705                rollback_requests: 24,
12706                overhead_budget_us: u64::MAX,
12707                overhead_backoff_requests: 1,
12708            };
12709            let dispatcher = ExtensionDispatcher::new_with_policy_and_oracle_config(
12710                Rc::clone(&runtime),
12711                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
12712                Arc::new(HttpConnector::with_defaults()),
12713                session,
12714                Arc::new(NullUiHandler),
12715                PathBuf::from("."),
12716                ExtensionPolicy::from_profile(PolicyProfile::Permissive),
12717                oracle_config,
12718            );
12719
12720            for idx in 0..3_u64 {
12721                let request = HostcallRequest {
12722                    call_id: format!("dual-divergence-{idx}"),
12723                    kind: HostcallKind::Session {
12724                        op: "get_state".to_string(),
12725                    },
12726                    payload: serde_json::json!({}),
12727                    trace_id: idx,
12728                    extension_id: Some("ext.shadow.rollback".to_string()),
12729                };
12730                dispatcher.dispatch_and_complete(request).await;
12731            }
12732
12733            let state = dispatcher.dual_exec_state.borrow();
12734            assert!(
12735                state.divergence_total >= 2,
12736                "expected enough divergence samples to trip rollback"
12737            );
12738            assert!(state.rollback_active(), "rollback should be active");
12739            assert!(
12740                state
12741                    .rollback_reason
12742                    .as_deref()
12743                    .is_some_and(|reason| reason.contains("ext.shadow.rollback")),
12744                "rollback reason should include extension scope"
12745            );
12746        });
12747    }
12748
12749    #[test]
12750    fn dual_exec_rollback_forces_dispatch_batch_amac_to_skip_planning() {
12751        futures::executor::block_on(async {
12752            let runtime = Rc::new(
12753                PiJsRuntime::with_clock(DeterministicClock::new(0))
12754                    .await
12755                    .expect("runtime"),
12756            );
12757            let oracle_config = DualExecOracleConfig {
12758                sample_ppm: 0,
12759                divergence_window: 8,
12760                divergence_budget: 2,
12761                rollback_requests: 16,
12762                overhead_budget_us: 1_500,
12763                overhead_backoff_requests: 8,
12764            };
12765            let dispatcher = ExtensionDispatcher::new_with_policy_and_oracle_config(
12766                Rc::clone(&runtime),
12767                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
12768                Arc::new(HttpConnector::with_defaults()),
12769                Arc::new(NullSession),
12770                Arc::new(NullUiHandler),
12771                PathBuf::from("."),
12772                ExtensionPolicy::from_profile(PolicyProfile::Permissive),
12773                oracle_config,
12774            );
12775
12776            {
12777                let mut amac = dispatcher.amac_executor.borrow_mut();
12778                *amac = AmacBatchExecutor::new(AmacBatchExecutorConfig::new(true, 2, 8));
12779            }
12780
12781            {
12782                let mut detector = dispatcher.regime_detector.borrow_mut();
12783                drive_detector_to_interleaved(&mut detector);
12784            }
12785
12786            let mut baseline = VecDeque::new();
12787            for idx in 0..4_u64 {
12788                baseline.push_back(HostcallRequest {
12789                    call_id: format!("baseline-{idx}"),
12790                    kind: HostcallKind::Session {
12791                        op: "get_state".to_string(),
12792                    },
12793                    payload: serde_json::json!({}),
12794                    trace_id: idx,
12795                    extension_id: Some("ext.roll".to_string()),
12796                });
12797            }
12798            dispatcher.dispatch_batch_amac(baseline).await;
12799            let baseline_decisions = dispatcher
12800                .amac_executor
12801                .borrow()
12802                .telemetry()
12803                .toggle_decisions;
12804            assert!(
12805                baseline_decisions > 0,
12806                "expected AMAC planner to run before rollback activation"
12807            );
12808
12809            {
12810                let mut state = dispatcher.dual_exec_state.borrow_mut();
12811                state.rollback_remaining = 16;
12812                state.rollback_reason =
12813                    Some("dual_exec_divergence_budget_exceeded:test".to_string());
12814            }
12815
12816            let mut rollback_batch = VecDeque::new();
12817            for idx in 0..4_u64 {
12818                rollback_batch.push_back(HostcallRequest {
12819                    call_id: format!("rollback-{idx}"),
12820                    kind: HostcallKind::Session {
12821                        op: "get_state".to_string(),
12822                    },
12823                    payload: serde_json::json!({}),
12824                    trace_id: idx + 100,
12825                    extension_id: Some("ext.roll".to_string()),
12826                });
12827            }
12828            dispatcher.dispatch_batch_amac(rollback_batch).await;
12829
12830            let after_rollback = dispatcher
12831                .amac_executor
12832                .borrow()
12833                .telemetry()
12834                .toggle_decisions;
12835            assert_eq!(
12836                after_rollback, baseline_decisions,
12837                "rollback path should bypass AMAC planning and keep toggle decisions unchanged"
12838            );
12839        });
12840    }
12841
12842    #[test]
12843    fn rollout_mode_controls_amac_planner_activation() {
12844        futures::executor::block_on(async {
12845            let runtime = Rc::new(
12846                PiJsRuntime::with_clock(DeterministicClock::new(0))
12847                    .await
12848                    .expect("runtime"),
12849            );
12850            let dispatcher = build_dispatcher(Rc::clone(&runtime));
12851            {
12852                let mut amac = dispatcher.amac_executor.borrow_mut();
12853                *amac = AmacBatchExecutor::new(AmacBatchExecutorConfig::new(true, 2, 8));
12854            }
12855
12856            let mut sequential_batch = VecDeque::new();
12857            for idx in 0..4_u64 {
12858                sequential_batch.push_back(HostcallRequest {
12859                    call_id: format!("rollout-seq-{idx}"),
12860                    kind: HostcallKind::Session {
12861                        op: "get_state".to_string(),
12862                    },
12863                    payload: serde_json::json!({}),
12864                    trace_id: idx,
12865                    extension_id: Some("ext.rollout.mode".to_string()),
12866                });
12867            }
12868            dispatcher.dispatch_batch_amac(sequential_batch).await;
12869            let decisions_after_seq = dispatcher
12870                .amac_executor
12871                .borrow()
12872                .telemetry()
12873                .toggle_decisions;
12874            assert_eq!(
12875                decisions_after_seq, 0,
12876                "sequential rollout mode should skip AMAC planning"
12877            );
12878
12879            {
12880                let mut detector = dispatcher.regime_detector.borrow_mut();
12881                drive_detector_to_interleaved(&mut detector);
12882            }
12883
12884            let mut interleaved_batch = VecDeque::new();
12885            for idx in 0..4_u64 {
12886                interleaved_batch.push_back(HostcallRequest {
12887                    call_id: format!("rollout-interleaved-{idx}"),
12888                    kind: HostcallKind::Session {
12889                        op: "get_state".to_string(),
12890                    },
12891                    payload: serde_json::json!({}),
12892                    trace_id: idx + 100,
12893                    extension_id: Some("ext.rollout.mode".to_string()),
12894                });
12895            }
12896            dispatcher.dispatch_batch_amac(interleaved_batch).await;
12897            let decisions_after_interleaved = dispatcher
12898                .amac_executor
12899                .borrow()
12900                .telemetry()
12901                .toggle_decisions;
12902            assert!(
12903                decisions_after_interleaved > decisions_after_seq,
12904                "promotion should enable AMAC planning"
12905            );
12906        });
12907    }
12908
12909    #[test]
12910    fn hostcall_io_hint_marks_expected_kinds_as_io_heavy() {
12911        assert_eq!(
12912            hostcall_io_hint(&HostcallKind::Http),
12913            HostcallIoHint::IoHeavy
12914        );
12915        assert_eq!(
12916            hostcall_io_hint(&HostcallKind::Tool {
12917                name: "read".to_string()
12918            }),
12919            HostcallIoHint::IoHeavy
12920        );
12921        assert_eq!(
12922            hostcall_io_hint(&HostcallKind::Session {
12923                op: "append_message".to_string()
12924            }),
12925            HostcallIoHint::IoHeavy
12926        );
12927    }
12928
12929    #[test]
12930    fn hostcall_io_hint_marks_non_io_kinds_as_non_heavy() {
12931        assert_eq!(
12932            hostcall_io_hint(&HostcallKind::Ui {
12933                op: "prompt".to_string()
12934            }),
12935            HostcallIoHint::CpuBound
12936        );
12937        assert_eq!(
12938            hostcall_io_hint(&HostcallKind::Tool {
12939                name: "unknown_tool".to_string()
12940            }),
12941            HostcallIoHint::Unknown
12942        );
12943        assert_eq!(
12944            hostcall_io_hint(&HostcallKind::Session {
12945                op: "get_state".to_string()
12946            }),
12947            HostcallIoHint::Unknown
12948        );
12949    }
12950
12951    #[test]
12952    fn hostcall_io_hint_classifies_edit_bash_and_exec() {
12953        assert_eq!(
12954            hostcall_io_hint(&HostcallKind::Tool {
12955                name: "edit".to_string()
12956            }),
12957            HostcallIoHint::IoHeavy,
12958            "edit tool should be IoHeavy"
12959        );
12960        assert_eq!(
12961            hostcall_io_hint(&HostcallKind::Tool {
12962                name: "bash".to_string()
12963            }),
12964            HostcallIoHint::CpuBound,
12965            "bash tool should be CpuBound"
12966        );
12967        assert_eq!(
12968            hostcall_io_hint(&HostcallKind::Exec {
12969                cmd: "ls".to_string()
12970            }),
12971            HostcallIoHint::CpuBound,
12972            "exec hostcall should be CpuBound"
12973        );
12974    }
12975
12976    #[test]
12977    fn io_uring_bridge_reports_cancellation_when_request_not_pending() {
12978        futures::executor::block_on(async {
12979            let runtime = Rc::new(
12980                PiJsRuntime::with_clock(DeterministicClock::new(0))
12981                    .await
12982                    .expect("runtime"),
12983            );
12984            let dispatcher = build_dispatcher(Rc::clone(&runtime));
12985            let request = HostcallRequest {
12986                call_id: "cancelled-before-io-uring".to_string(),
12987                kind: HostcallKind::Http,
12988                payload: serde_json::json!({
12989                    "url": "https://example.com",
12990                    "method": "GET",
12991                }),
12992                trace_id: 1,
12993                extension_id: Some("ext.cancel".to_string()),
12994            };
12995            let bridge_dispatch = dispatcher.dispatch_hostcall_io_uring(&request).await;
12996            assert_eq!(
12997                bridge_dispatch.state,
12998                IoUringBridgeState::CancelledBeforeDispatch
12999            );
13000            assert_eq!(
13001                bridge_dispatch.fallback_reason,
13002                Some("cancelled_before_io_uring_dispatch")
13003            );
13004            match bridge_dispatch.outcome {
13005                HostcallOutcome::Error { code, message } => {
13006                    assert_eq!(code, "cancelled");
13007                    assert!(
13008                        message.contains("cancelled before io_uring dispatch"),
13009                        "unexpected cancellation message: {message}"
13010                    );
13011                }
13012                other => panic!(),
13013            }
13014        });
13015    }
13016
13017    // ========================================================================
13018    // bd-3ar8v.4.8.21: Protocol error-code taxonomy and validation tests
13019    // ========================================================================
13020
13021    #[test]
13022    fn protocol_error_code_timeout_maps_correctly() {
13023        assert_eq!(protocol_error_code("timeout"), HostCallErrorCode::Timeout);
13024    }
13025
13026    #[test]
13027    fn protocol_error_code_denied_maps_correctly() {
13028        assert_eq!(protocol_error_code("denied"), HostCallErrorCode::Denied);
13029    }
13030
13031    #[test]
13032    fn protocol_error_code_io_maps_correctly() {
13033        assert_eq!(protocol_error_code("io"), HostCallErrorCode::Io);
13034    }
13035
13036    #[test]
13037    fn protocol_error_code_tool_error_maps_to_io() {
13038        assert_eq!(protocol_error_code("tool_error"), HostCallErrorCode::Io);
13039    }
13040
13041    #[test]
13042    fn protocol_error_code_invalid_request_maps_correctly() {
13043        assert_eq!(
13044            protocol_error_code("invalid_request"),
13045            HostCallErrorCode::InvalidRequest
13046        );
13047    }
13048
13049    #[test]
13050    fn protocol_error_code_completely_unknown_maps_to_internal() {
13051        assert_eq!(
13052            protocol_error_code("completely_unknown"),
13053            HostCallErrorCode::Internal
13054        );
13055    }
13056
13057    #[test]
13058    fn protocol_error_code_empty_string_maps_to_internal() {
13059        assert_eq!(protocol_error_code(""), HostCallErrorCode::Internal);
13060    }
13061
13062    #[test]
13063    fn protocol_error_code_whitespace_only_maps_to_internal() {
13064        assert_eq!(protocol_error_code("   "), HostCallErrorCode::Internal);
13065    }
13066
13067    #[test]
13068    fn protocol_error_code_case_insensitive_timeout() {
13069        assert_eq!(protocol_error_code("TIMEOUT"), HostCallErrorCode::Timeout);
13070        assert_eq!(protocol_error_code("Timeout"), HostCallErrorCode::Timeout);
13071        assert_eq!(protocol_error_code("TimeOut"), HostCallErrorCode::Timeout);
13072    }
13073
13074    #[test]
13075    fn protocol_error_code_case_insensitive_denied() {
13076        assert_eq!(protocol_error_code("DENIED"), HostCallErrorCode::Denied);
13077        assert_eq!(protocol_error_code("Denied"), HostCallErrorCode::Denied);
13078    }
13079
13080    #[test]
13081    fn protocol_error_code_case_insensitive_io() {
13082        assert_eq!(protocol_error_code("IO"), HostCallErrorCode::Io);
13083        assert_eq!(protocol_error_code("Io"), HostCallErrorCode::Io);
13084        assert_eq!(protocol_error_code("TOOL_ERROR"), HostCallErrorCode::Io);
13085        assert_eq!(protocol_error_code("Tool_Error"), HostCallErrorCode::Io);
13086    }
13087
13088    #[test]
13089    fn protocol_error_code_case_insensitive_invalid_request() {
13090        assert_eq!(
13091            protocol_error_code("INVALID_REQUEST"),
13092            HostCallErrorCode::InvalidRequest
13093        );
13094        assert_eq!(
13095            protocol_error_code("Invalid_Request"),
13096            HostCallErrorCode::InvalidRequest
13097        );
13098    }
13099
13100    #[test]
13101    fn protocol_error_code_trims_whitespace() {
13102        assert_eq!(
13103            protocol_error_code("  timeout  "),
13104            HostCallErrorCode::Timeout
13105        );
13106        assert_eq!(protocol_error_code("\tdenied\n"), HostCallErrorCode::Denied);
13107    }
13108
13109    #[test]
13110    fn parse_protocol_hostcall_method_all_known_methods() {
13111        assert_eq!(
13112            parse_protocol_hostcall_method("tool"),
13113            Some(ProtocolHostcallMethod::Tool)
13114        );
13115        assert_eq!(
13116            parse_protocol_hostcall_method("exec"),
13117            Some(ProtocolHostcallMethod::Exec)
13118        );
13119        assert_eq!(
13120            parse_protocol_hostcall_method("http"),
13121            Some(ProtocolHostcallMethod::Http)
13122        );
13123        assert_eq!(
13124            parse_protocol_hostcall_method("session"),
13125            Some(ProtocolHostcallMethod::Session)
13126        );
13127        assert_eq!(
13128            parse_protocol_hostcall_method("ui"),
13129            Some(ProtocolHostcallMethod::Ui)
13130        );
13131        assert_eq!(
13132            parse_protocol_hostcall_method("events"),
13133            Some(ProtocolHostcallMethod::Events)
13134        );
13135        assert_eq!(
13136            parse_protocol_hostcall_method("log"),
13137            Some(ProtocolHostcallMethod::Log)
13138        );
13139    }
13140
13141    #[test]
13142    fn parse_protocol_hostcall_method_case_insensitive() {
13143        assert_eq!(
13144            parse_protocol_hostcall_method("TOOL"),
13145            Some(ProtocolHostcallMethod::Tool)
13146        );
13147        assert_eq!(
13148            parse_protocol_hostcall_method("Tool"),
13149            Some(ProtocolHostcallMethod::Tool)
13150        );
13151        assert_eq!(
13152            parse_protocol_hostcall_method("SESSION"),
13153            Some(ProtocolHostcallMethod::Session)
13154        );
13155        assert_eq!(
13156            parse_protocol_hostcall_method("Events"),
13157            Some(ProtocolHostcallMethod::Events)
13158        );
13159    }
13160
13161    #[test]
13162    fn parse_protocol_hostcall_method_trims_whitespace() {
13163        assert_eq!(
13164            parse_protocol_hostcall_method("  tool  "),
13165            Some(ProtocolHostcallMethod::Tool)
13166        );
13167        assert_eq!(
13168            parse_protocol_hostcall_method("\texec\n"),
13169            Some(ProtocolHostcallMethod::Exec)
13170        );
13171    }
13172
13173    #[test]
13174    fn parse_protocol_hostcall_method_rejects_unknown() {
13175        assert_eq!(parse_protocol_hostcall_method("unknown"), None);
13176        assert_eq!(parse_protocol_hostcall_method("foobar"), None);
13177        assert_eq!(parse_protocol_hostcall_method("tools"), None);
13178    }
13179
13180    #[test]
13181    fn parse_protocol_hostcall_method_rejects_empty() {
13182        assert_eq!(parse_protocol_hostcall_method(""), None);
13183        assert_eq!(parse_protocol_hostcall_method("   "), None);
13184    }
13185
13186    #[test]
13187    fn protocol_normalize_output_preserves_objects() {
13188        let obj = serde_json::json!({"key": "value", "nested": {"a": 1}});
13189        let result = protocol_normalize_output(obj.clone());
13190        assert_eq!(result, obj);
13191    }
13192
13193    #[test]
13194    fn protocol_normalize_output_wraps_string() {
13195        let val = serde_json::json!("hello");
13196        let result = protocol_normalize_output(val);
13197        assert_eq!(result, serde_json::json!({"value": "hello"}));
13198    }
13199
13200    #[test]
13201    fn protocol_normalize_output_wraps_number() {
13202        let val = serde_json::json!(42);
13203        let result = protocol_normalize_output(val);
13204        assert_eq!(result, serde_json::json!({"value": 42}));
13205    }
13206
13207    #[test]
13208    fn protocol_normalize_output_wraps_bool() {
13209        let val = serde_json::json!(true);
13210        let result = protocol_normalize_output(val);
13211        assert_eq!(result, serde_json::json!({"value": true}));
13212    }
13213
13214    #[test]
13215    fn protocol_normalize_output_wraps_null() {
13216        let val = Value::Null;
13217        let result = protocol_normalize_output(val);
13218        assert_eq!(result, serde_json::json!({"value": null}));
13219    }
13220
13221    #[test]
13222    fn protocol_normalize_output_wraps_array() {
13223        let val = serde_json::json!([1, 2, 3]);
13224        let result = protocol_normalize_output(val);
13225        assert_eq!(result, serde_json::json!({"value": [1, 2, 3]}));
13226    }
13227
13228    #[test]
13229    fn protocol_normalize_output_preserves_empty_object() {
13230        let val = serde_json::json!({});
13231        let result = protocol_normalize_output(val.clone());
13232        assert_eq!(result, val);
13233    }
13234
13235    #[test]
13236    fn protocol_error_fallback_reason_denied() {
13237        assert_eq!(
13238            protocol_error_fallback_reason("tool", "denied"),
13239            "policy_denied"
13240        );
13241        assert_eq!(
13242            protocol_error_fallback_reason("exec", "DENIED"),
13243            "policy_denied"
13244        );
13245    }
13246
13247    #[test]
13248    fn protocol_error_fallback_reason_timeout() {
13249        assert_eq!(
13250            protocol_error_fallback_reason("tool", "timeout"),
13251            "handler_timeout"
13252        );
13253    }
13254
13255    #[test]
13256    fn protocol_error_fallback_reason_io() {
13257        assert_eq!(
13258            protocol_error_fallback_reason("tool", "io"),
13259            "handler_error"
13260        );
13261        assert_eq!(
13262            protocol_error_fallback_reason("exec", "tool_error"),
13263            "handler_error"
13264        );
13265    }
13266
13267    #[test]
13268    fn protocol_error_fallback_reason_invalid_request_known_method() {
13269        assert_eq!(
13270            protocol_error_fallback_reason("tool", "invalid_request"),
13271            "schema_validation_failed"
13272        );
13273        assert_eq!(
13274            protocol_error_fallback_reason("session", "invalid_request"),
13275            "schema_validation_failed"
13276        );
13277    }
13278
13279    #[test]
13280    fn protocol_error_fallback_reason_invalid_request_unknown_method() {
13281        assert_eq!(
13282            protocol_error_fallback_reason("nonexistent", "invalid_request"),
13283            "unsupported_method_fallback"
13284        );
13285    }
13286
13287    #[test]
13288    fn protocol_error_fallback_reason_unknown_code() {
13289        assert_eq!(
13290            protocol_error_fallback_reason("tool", "something_else"),
13291            "runtime_internal_error"
13292        );
13293        assert_eq!(
13294            protocol_error_fallback_reason("tool", ""),
13295            "runtime_internal_error"
13296        );
13297    }
13298
13299    #[test]
13300    fn protocol_error_details_structure_complete() {
13301        let payload = HostCallPayload {
13302            call_id: "test-call-1".to_string(),
13303            capability: "tool".to_string(),
13304            method: "tool".to_string(),
13305            params: serde_json::json!({"name": "read", "input": {"path": "/tmp/test"}}),
13306            timeout_ms: None,
13307            cancel_token: None,
13308            context: None,
13309        };
13310
13311        let details = protocol_error_details(&payload, "invalid_request", "Tool not found");
13312
13313        // Verify top-level structure
13314        assert!(details.get("dispatcherDecisionTrace").is_some());
13315        assert!(details.get("schemaDiff").is_some());
13316        assert!(details.get("extensionInput").is_some());
13317        assert!(details.get("extensionOutput").is_some());
13318
13319        // Verify dispatcher decision trace
13320        let trace = &details["dispatcherDecisionTrace"];
13321        assert_eq!(trace["selectedRuntime"], "rust-extension-dispatcher");
13322        assert_eq!(trace["schemaVersion"], PROTOCOL_VERSION);
13323        assert_eq!(trace["method"], "tool");
13324        assert_eq!(trace["capability"], "tool");
13325        assert_eq!(trace["fallbackReason"], "schema_validation_failed");
13326
13327        // Verify schema diff has sorted keys
13328        let observed_keys = details["schemaDiff"]["observedParamKeys"]
13329            .as_array()
13330            .expect("observedParamKeys must be array");
13331        let keys: Vec<&str> = observed_keys.iter().filter_map(|v| v.as_str()).collect();
13332        assert_eq!(keys, vec!["input", "name"]);
13333
13334        // Verify extension input
13335        assert_eq!(details["extensionInput"]["callId"], "test-call-1");
13336        assert_eq!(details["extensionInput"]["capability"], "tool");
13337        assert_eq!(details["extensionInput"]["method"], "tool");
13338
13339        // Verify extension output
13340        assert_eq!(details["extensionOutput"]["code"], "invalid_request");
13341        assert_eq!(details["extensionOutput"]["message"], "Tool not found");
13342    }
13343
13344    #[test]
13345    fn protocol_error_details_non_object_params_yields_empty_keys() {
13346        let payload = HostCallPayload {
13347            call_id: "test-call-2".to_string(),
13348            capability: "exec".to_string(),
13349            method: "exec".to_string(),
13350            params: serde_json::json!("not an object"),
13351            timeout_ms: None,
13352            cancel_token: None,
13353            context: None,
13354        };
13355
13356        let details = protocol_error_details(&payload, "io", "exec failed");
13357        let observed_keys = details["schemaDiff"]["observedParamKeys"]
13358            .as_array()
13359            .expect("must be array");
13360        assert!(observed_keys.is_empty());
13361        assert_eq!(
13362            details["dispatcherDecisionTrace"]["fallbackReason"],
13363            "handler_error"
13364        );
13365    }
13366
13367    #[test]
13368    fn protocol_hostcall_op_extracts_from_op_key() {
13369        let params = serde_json::json!({"op": "getState"});
13370        assert_eq!(protocol_hostcall_op(&params), Some("getState"));
13371    }
13372
13373    #[test]
13374    fn protocol_hostcall_op_extracts_from_method_key() {
13375        let params = serde_json::json!({"method": "setModel"});
13376        assert_eq!(protocol_hostcall_op(&params), Some("setModel"));
13377    }
13378
13379    #[test]
13380    fn protocol_hostcall_op_extracts_from_name_key() {
13381        let params = serde_json::json!({"name": "read"});
13382        assert_eq!(protocol_hostcall_op(&params), Some("read"));
13383    }
13384
13385    #[test]
13386    fn protocol_hostcall_op_prefers_op_over_method() {
13387        let params = serde_json::json!({"op": "first", "method": "second"});
13388        assert_eq!(protocol_hostcall_op(&params), Some("first"));
13389    }
13390
13391    #[test]
13392    fn protocol_hostcall_op_prefers_method_over_name() {
13393        let params = serde_json::json!({"method": "first", "name": "second"});
13394        assert_eq!(protocol_hostcall_op(&params), Some("first"));
13395    }
13396
13397    #[test]
13398    fn protocol_hostcall_op_returns_none_for_empty_params() {
13399        let params = serde_json::json!({});
13400        assert_eq!(protocol_hostcall_op(&params), None);
13401    }
13402
13403    #[test]
13404    fn protocol_hostcall_op_returns_none_for_empty_string_value() {
13405        let params = serde_json::json!({"op": ""});
13406        assert_eq!(protocol_hostcall_op(&params), None);
13407    }
13408
13409    #[test]
13410    fn protocol_hostcall_op_returns_none_for_whitespace_only_value() {
13411        let params = serde_json::json!({"op": "   "});
13412        assert_eq!(protocol_hostcall_op(&params), None);
13413    }
13414
13415    #[test]
13416    fn protocol_hostcall_op_trims_result() {
13417        let params = serde_json::json!({"op": "  getState  "});
13418        assert_eq!(protocol_hostcall_op(&params), Some("getState"));
13419    }
13420
13421    #[test]
13422    fn protocol_hostcall_op_returns_none_for_non_string_value() {
13423        let params = serde_json::json!({"op": 42});
13424        assert_eq!(protocol_hostcall_op(&params), None);
13425    }
13426
13427    #[test]
13428    fn hostcall_outcome_to_protocol_result_success_normalizes_output() {
13429        let result = hostcall_outcome_to_protocol_result(
13430            "call-s1",
13431            HostcallOutcome::Success(serde_json::json!({"result": "ok"})),
13432        );
13433        assert_eq!(result.call_id, "call-s1");
13434        assert!(!result.is_error);
13435        assert!(result.error.is_none());
13436        assert!(result.chunk.is_none());
13437        assert_eq!(result.output, serde_json::json!({"result": "ok"}));
13438    }
13439
13440    #[test]
13441    fn hostcall_outcome_to_protocol_result_success_wraps_plain_string() {
13442        let result = hostcall_outcome_to_protocol_result(
13443            "call-s2",
13444            HostcallOutcome::Success(serde_json::json!("plain string")),
13445        );
13446        assert_eq!(result.output, serde_json::json!({"value": "plain string"}));
13447    }
13448
13449    #[test]
13450    fn hostcall_outcome_to_protocol_result_error_maps_code() {
13451        let result = hostcall_outcome_to_protocol_result(
13452            "call-e1",
13453            HostcallOutcome::Error {
13454                code: "denied".to_string(),
13455                message: "not allowed".to_string(),
13456            },
13457        );
13458        assert_eq!(result.call_id, "call-e1");
13459        assert!(result.is_error);
13460        let err = result.error.as_ref().expect("error payload");
13461        assert_eq!(err.code, HostCallErrorCode::Denied);
13462        assert_eq!(err.message, "not allowed");
13463        assert!(err.details.is_none());
13464        assert!(result.output.is_object());
13465    }
13466
13467    #[test]
13468    fn hostcall_outcome_to_protocol_result_error_unknown_code_maps_internal() {
13469        let result = hostcall_outcome_to_protocol_result(
13470            "call-e2",
13471            HostcallOutcome::Error {
13472                code: "mystery_error".to_string(),
13473                message: "something broke".to_string(),
13474            },
13475        );
13476        let err = result.error.as_ref().expect("error payload");
13477        assert_eq!(err.code, HostCallErrorCode::Internal);
13478    }
13479
13480    #[test]
13481    fn hostcall_outcome_to_protocol_result_stream_partial_chunk() {
13482        let result = hostcall_outcome_to_protocol_result(
13483            "call-sc1",
13484            HostcallOutcome::StreamChunk {
13485                sequence: 5,
13486                chunk: serde_json::json!({"data": "partial"}),
13487                is_final: false,
13488            },
13489        );
13490        assert_eq!(result.call_id, "call-sc1");
13491        assert!(!result.is_error);
13492        assert!(result.error.is_none());
13493        let chunk = result.chunk.as_ref().expect("chunk metadata");
13494        assert_eq!(chunk.index, 5);
13495        assert!(!chunk.is_last);
13496        assert_eq!(result.output["sequence"], 5);
13497        assert_eq!(result.output["isFinal"], false);
13498    }
13499
13500    #[test]
13501    fn hostcall_outcome_to_protocol_result_stream_final_chunk() {
13502        let result = hostcall_outcome_to_protocol_result(
13503            "call-sc2",
13504            HostcallOutcome::StreamChunk {
13505                sequence: 10,
13506                chunk: serde_json::json!(null),
13507                is_final: true,
13508            },
13509        );
13510        let chunk = result.chunk.as_ref().expect("chunk metadata");
13511        assert!(chunk.is_last);
13512        assert_eq!(result.output["isFinal"], true);
13513    }
13514
13515    #[test]
13516    fn hostcall_outcome_to_protocol_result_with_trace_error_includes_details() {
13517        let payload = HostCallPayload {
13518            call_id: "call-trace-1".to_string(),
13519            capability: "tool".to_string(),
13520            method: "tool".to_string(),
13521            params: serde_json::json!({"name": "read"}),
13522            timeout_ms: None,
13523            cancel_token: None,
13524            context: None,
13525        };
13526
13527        let result = hostcall_outcome_to_protocol_result_with_trace(
13528            &payload,
13529            HostcallOutcome::Error {
13530                code: "timeout".to_string(),
13531                message: "operation timed out".to_string(),
13532            },
13533        );
13534
13535        assert!(result.is_error);
13536        let err = result.error.as_ref().expect("error");
13537        assert_eq!(err.code, HostCallErrorCode::Timeout);
13538        assert_eq!(err.message, "operation timed out");
13539
13540        // With-trace variant must include details
13541        let details = err.details.as_ref().expect("details must be present");
13542        assert!(details.get("dispatcherDecisionTrace").is_some());
13543        assert_eq!(
13544            details["dispatcherDecisionTrace"]["fallbackReason"],
13545            "handler_timeout"
13546        );
13547    }
13548
13549    #[test]
13550    fn hostcall_outcome_to_protocol_result_with_trace_success_no_details() {
13551        let payload = HostCallPayload {
13552            call_id: "call-trace-2".to_string(),
13553            capability: "tool".to_string(),
13554            method: "tool".to_string(),
13555            params: serde_json::json!({"name": "read"}),
13556            timeout_ms: None,
13557            cancel_token: None,
13558            context: None,
13559        };
13560
13561        let result = hostcall_outcome_to_protocol_result_with_trace(
13562            &payload,
13563            HostcallOutcome::Success(serde_json::json!({"content": "file data"})),
13564        );
13565
13566        assert!(!result.is_error);
13567        assert!(result.error.is_none());
13568        assert_eq!(result.output["content"], "file data");
13569    }
13570
13571    // ── Property tests ──
13572
13573    mod proptest_dispatcher {
13574        use super::*;
13575        use proptest::prelude::*;
13576
13577        proptest! {
13578            #[test]
13579            fn shannon_entropy_nonnegative(bytes in prop::collection::vec(any::<u8>(), 0..200)) {
13580                let entropy = shannon_entropy_bytes(&bytes);
13581                assert!(
13582                    entropy >= 0.0,
13583                    "entropy must be non-negative, got {entropy}"
13584                );
13585            }
13586
13587            #[test]
13588            fn shannon_entropy_bounded_by_log2_256(
13589                bytes in prop::collection::vec(any::<u8>(), 1..200),
13590            ) {
13591                let entropy = shannon_entropy_bytes(&bytes);
13592                assert!(
13593                    entropy <= 8.0 + f64::EPSILON,
13594                    "entropy must be <= 8.0 (log2(256)), got {entropy}"
13595                );
13596            }
13597
13598            #[test]
13599            fn shannon_entropy_empty_is_zero(_dummy in Just(())) {
13600                assert!(
13601                    (shannon_entropy_bytes(&[]) - 0.0).abs() < f64::EPSILON,
13602                    "entropy of empty input must be 0.0"
13603                );
13604            }
13605
13606            #[test]
13607            fn shannon_entropy_single_byte_is_zero(byte in any::<u8>()) {
13608                let entropy = shannon_entropy_bytes(&[byte]);
13609                assert!(
13610                    entropy.abs() < f64::EPSILON,
13611                    "entropy of single byte must be 0.0, got {entropy}"
13612                );
13613            }
13614
13615            #[test]
13616            fn shannon_entropy_uniform_is_maximal(
13617                len in 256..512usize,
13618            ) {
13619                // Construct input with every byte value appearing equally
13620                #[allow(clippy::cast_possible_truncation)]
13621                let bytes: Vec<u8> = (0..len).map(|i| (i % 256) as u8).collect();
13622                let entropy = shannon_entropy_bytes(&bytes);
13623                // Should be close to 8.0 (log2(256))
13624                assert!(
13625                    entropy > 7.9,
13626                    "uniform distribution entropy should be near 8.0, got {entropy}"
13627                );
13628            }
13629
13630            #[test]
13631            fn llc_miss_proxy_bounded(
13632                total_depth in 0..10_000usize,
13633                overflow_depth in 0..10_000usize,
13634                rejected_total in 0..100_000u64,
13635            ) {
13636                let proxy = llc_miss_proxy(total_depth, overflow_depth, rejected_total);
13637                assert!(
13638                    (0.0..=1.0).contains(&proxy),
13639                    "llc_miss_proxy must be in [0.0, 1.0], got {proxy}"
13640                );
13641            }
13642
13643            #[test]
13644            fn llc_miss_proxy_zero_on_empty(_dummy in Just(())) {
13645                let proxy = llc_miss_proxy(0, 0, 0);
13646                assert!(
13647                    proxy.abs() < f64::EPSILON,
13648                    "llc_miss_proxy(0, 0, 0) must be 0.0"
13649                );
13650            }
13651
13652            #[test]
13653            fn normalized_shadow_op_idempotent(op in "[a-zA-Z_]{1,20}") {
13654                let once = normalized_shadow_op(&op);
13655                let twice = normalized_shadow_op(&once);
13656                assert!(
13657                    once == twice,
13658                    "normalized_shadow_op must be idempotent: '{once}' vs '{twice}'"
13659                );
13660            }
13661
13662            #[test]
13663            fn normalized_shadow_op_case_insensitive(op in "[a-zA-Z]{1,20}") {
13664                let lower = normalized_shadow_op(&op.to_lowercase());
13665                let upper = normalized_shadow_op(&op.to_uppercase());
13666                assert!(
13667                    lower == upper,
13668                    "normalized_shadow_op must be case-insensitive: '{lower}' vs '{upper}'"
13669                );
13670            }
13671
13672            #[test]
13673            fn shadow_safe_session_op_case_insensitive(
13674                op in prop::sample::select(vec![
13675                    "getState".to_string(),
13676                    "GETSTATE".to_string(),
13677                    "get_state".to_string(),
13678                    "GET_STATE".to_string(),
13679                    "getMessages".to_string(),
13680                    "GET_MESSAGES".to_string(),
13681                ]),
13682            ) {
13683                assert!(
13684                    shadow_safe_session_op(&op),
13685                    "'{op}' should be recognized as safe session op"
13686                );
13687            }
13688
13689            #[test]
13690            fn shadow_safe_tool_case_insensitive(
13691                name in prop::sample::select(vec![
13692                    "Read".to_string(),
13693                    "READ".to_string(),
13694                    "read".to_string(),
13695                    "Grep".to_string(),
13696                    "GREP".to_string(),
13697                ]),
13698            ) {
13699                assert!(
13700                    shadow_safe_tool(&name),
13701                    "'{name}' should be safe tool"
13702                );
13703            }
13704
13705            #[test]
13706            fn usize_to_f64_monotonic(a in 0..u32::MAX as usize, b in 0..u32::MAX as usize) {
13707                let fa = usize_to_f64(a);
13708                let fb = usize_to_f64(b);
13709                if a <= b {
13710                    assert!(
13711                        fa <= fb,
13712                        "usize_to_f64 must be monotonic: {a} → {fa}, {b} → {fb}"
13713                    );
13714                }
13715            }
13716        }
13717    }
13718}