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::channel::oneshot;
18use asupersync::time::{sleep, wall_now};
19use async_trait::async_trait;
20use serde_json::Value;
21use sha2::Digest as _;
22
23use crate::connectors::{Connector, http::HttpConnector};
24use crate::error::Result;
25use crate::extensions::EXTENSION_EVENT_TIMEOUT_MS;
26use crate::extensions::{
27    DangerousCommandClass, ExecMediationResult, ExtensionBody, ExtensionMessage, ExtensionPolicy,
28    ExtensionSession, ExtensionUiRequest, ExtensionUiResponse, HostCallError, HostCallErrorCode,
29    HostCallPayload, HostResultPayload, HostStreamChunk, PROTOCOL_VERSION, PolicyCheck,
30    PolicyDecision, PolicyProfile, PolicySnapshot, classify_ui_hostcall_error,
31    evaluate_exec_mediation, hash_canonical_json, required_capability_for_host_call_static,
32    ui_response_value_for_op, validate_host_call,
33};
34use crate::extensions_js::{HostcallKind, HostcallRequest, PiJsRuntime, js_to_json, json_to_js};
35use crate::hostcall_amac::{AmacBatchExecutor, AmacBatchExecutorConfig};
36use crate::hostcall_io_uring_lane::{
37    HostcallCapabilityClass, HostcallDispatchLane, HostcallIoHint, IoUringLaneDecisionInput,
38    IoUringLanePolicyConfig, decide_io_uring_lane,
39};
40use crate::scheduler::{Clock as SchedulerClock, HostcallOutcome, WallClock};
41use crate::tools::ToolRegistry;
42
43/// Coordinates hostcall dispatch between the JS extension runtime and Rust handlers.
44pub struct ExtensionDispatcher<C: SchedulerClock = WallClock> {
45    /// Runtime bridge used by the dispatcher.
46    runtime: Rc<dyn ExtensionDispatcherRuntime<C>>,
47    /// Registry of available tools (built-in + extension-registered).
48    tool_registry: Arc<ToolRegistry>,
49    /// HTTP connector for pi.http() calls.
50    http_connector: Arc<HttpConnector>,
51    /// Session access for pi.session() calls.
52    session: Arc<dyn ExtensionSession + Send + Sync>,
53    /// UI handler for pi.ui() calls.
54    ui_handler: Arc<dyn ExtensionUiHandler + Send + Sync>,
55    /// Current working directory for relative path resolution.
56    cwd: PathBuf,
57    /// Capability policy governing which hostcalls are allowed.
58    policy: ExtensionPolicy,
59    /// Precomputed O(1) capability decision table.
60    snapshot: PolicySnapshot,
61    /// Deterministic policy snapshot version hash for provenance/telemetry.
62    snapshot_version: String,
63    /// Configuration for sampled shadow dual execution.
64    dual_exec_config: DualExecOracleConfig,
65    /// Runtime state for sampled dual execution and rollback guards.
66    dual_exec_state: RefCell<DualExecOracleState>,
67    /// Decision-only io_uring lane policy for IO-dominant hostcalls.
68    io_uring_lane_config: IoUringLanePolicyConfig,
69    /// Kill switch forcing compatibility lane regardless of policy input.
70    io_uring_force_compat: bool,
71    /// Adaptive regime detector for hostcall workload shifts.
72    regime_detector: RefCell<RegimeShiftDetector>,
73    /// AMAC batch executor for interleaved hostcall dispatch.
74    amac_executor: RefCell<AmacBatchExecutor>,
75}
76
77/// Runtime bridge trait so dispatcher logic is not hardwired to a concrete runtime type.
78pub trait ExtensionDispatcherRuntime<C: SchedulerClock>: 'static {
79    fn as_js_runtime(&self) -> &PiJsRuntime<C>;
80}
81
82impl<C: SchedulerClock + 'static> ExtensionDispatcherRuntime<C> for PiJsRuntime<C> {
83    #[allow(clippy::use_self)]
84    fn as_js_runtime(&self) -> &PiJsRuntime<C> {
85        self
86    }
87}
88
89fn protocol_hostcall_op(params: &Value) -> Option<&str> {
90    params
91        .get("op")
92        .or_else(|| params.get("method"))
93        .or_else(|| params.get("name"))
94        .and_then(Value::as_str)
95        .map(str::trim)
96        .filter(|value| !value.is_empty())
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100enum ProtocolHostcallMethod {
101    Tool,
102    Exec,
103    Http,
104    Session,
105    Ui,
106    Events,
107    Log,
108}
109
110fn parse_protocol_hostcall_method(method: &str) -> Option<ProtocolHostcallMethod> {
111    let method = method.trim();
112    if method.is_empty() {
113        return None;
114    }
115
116    if method.eq_ignore_ascii_case("tool") {
117        Some(ProtocolHostcallMethod::Tool)
118    } else if method.eq_ignore_ascii_case("exec") {
119        Some(ProtocolHostcallMethod::Exec)
120    } else if method.eq_ignore_ascii_case("http") {
121        Some(ProtocolHostcallMethod::Http)
122    } else if method.eq_ignore_ascii_case("session") {
123        Some(ProtocolHostcallMethod::Session)
124    } else if method.eq_ignore_ascii_case("ui") {
125        Some(ProtocolHostcallMethod::Ui)
126    } else if method.eq_ignore_ascii_case("events") {
127        Some(ProtocolHostcallMethod::Events)
128    } else if method.eq_ignore_ascii_case("log") {
129        Some(ProtocolHostcallMethod::Log)
130    } else {
131        None
132    }
133}
134
135fn protocol_normalize_output(value: Value) -> Value {
136    if value.is_object() {
137        value
138    } else {
139        serde_json::json!({ "value": value })
140    }
141}
142
143fn policy_snapshot_version(policy: &ExtensionPolicy) -> String {
144    let mut hasher = sha2::Sha256::new();
145    match serde_json::to_value(policy) {
146        Ok(value) => hash_canonical_json(&value, &mut hasher),
147        Err(err) => hasher.update(err.to_string().as_bytes()),
148    }
149    format!("{:x}", hasher.finalize())
150}
151
152fn policy_lookup_path(capability: &str) -> &'static str {
153    let capability = capability.trim();
154    if capability.eq_ignore_ascii_case("read")
155        || capability.eq_ignore_ascii_case("write")
156        || capability.eq_ignore_ascii_case("exec")
157        || capability.eq_ignore_ascii_case("env")
158        || capability.eq_ignore_ascii_case("http")
159        || capability.eq_ignore_ascii_case("session")
160        || capability.eq_ignore_ascii_case("events")
161        || capability.eq_ignore_ascii_case("ui")
162        || capability.eq_ignore_ascii_case("log")
163        || capability.eq_ignore_ascii_case("tool")
164    {
165        "policy_snapshot_table"
166    } else {
167        "policy_snapshot_fallback"
168    }
169}
170
171fn protocol_error_code(code: &str) -> HostCallErrorCode {
172    let code = code.trim();
173    if code.eq_ignore_ascii_case("timeout") {
174        HostCallErrorCode::Timeout
175    } else if code.eq_ignore_ascii_case("denied") {
176        HostCallErrorCode::Denied
177    } else if code.eq_ignore_ascii_case("io") || code.eq_ignore_ascii_case("tool_error") {
178        HostCallErrorCode::Io
179    } else if code.eq_ignore_ascii_case("invalid_request") {
180        HostCallErrorCode::InvalidRequest
181    } else {
182        HostCallErrorCode::Internal
183    }
184}
185
186fn protocol_error_fallback_reason(method: &str, code: &str) -> &'static str {
187    let code = code.trim();
188    if code.eq_ignore_ascii_case("denied") {
189        "policy_denied"
190    } else if code.eq_ignore_ascii_case("timeout") {
191        "handler_timeout"
192    } else if code.eq_ignore_ascii_case("io") || code.eq_ignore_ascii_case("tool_error") {
193        "handler_error"
194    } else if code.eq_ignore_ascii_case("invalid_request") {
195        if parse_protocol_hostcall_method(method).is_some() {
196            "schema_validation_failed"
197        } else {
198            "unsupported_method_fallback"
199        }
200    } else {
201        "runtime_internal_error"
202    }
203}
204
205fn protocol_error_details(payload: &HostCallPayload, code: &str, message: &str) -> Value {
206    let observed_param_keys = payload
207        .params
208        .as_object()
209        .map(|object| {
210            let mut keys = object.keys().cloned().collect::<Vec<_>>();
211            keys.sort();
212            keys
213        })
214        .unwrap_or_default();
215
216    serde_json::json!({
217        "dispatcherDecisionTrace": {
218            "selectedRuntime": "rust-extension-dispatcher",
219            "schemaPath": "ExtensionBody::HostCall/HostCallPayload",
220            "schemaVersion": PROTOCOL_VERSION,
221            "method": payload.method,
222            "capability": payload.capability,
223            "fallbackReason": protocol_error_fallback_reason(&payload.method, code),
224        },
225        "schemaDiff": {
226            "observedParamKeys": observed_param_keys,
227        },
228        "extensionInput": {
229            "callId": payload.call_id,
230            "capability": payload.capability,
231            "method": payload.method,
232            "params": payload.params,
233        },
234        "extensionOutput": {
235            "code": code,
236            "message": message,
237        },
238    })
239}
240
241fn hostcall_outcome_to_protocol_result(
242    call_id: &str,
243    outcome: HostcallOutcome,
244) -> HostResultPayload {
245    match outcome {
246        HostcallOutcome::Success(output) => HostResultPayload {
247            call_id: call_id.to_string(),
248            output: protocol_normalize_output(output),
249            is_error: false,
250            error: None,
251            chunk: None,
252        },
253        HostcallOutcome::StreamChunk {
254            sequence,
255            chunk,
256            is_final,
257        } => HostResultPayload {
258            call_id: call_id.to_string(),
259            output: serde_json::json!({
260                "sequence": sequence,
261                "chunk": chunk,
262                "isFinal": is_final,
263            }),
264            is_error: false,
265            error: None,
266            chunk: Some(HostStreamChunk {
267                index: sequence,
268                is_last: is_final,
269                backpressure: None,
270            }),
271        },
272        HostcallOutcome::Error { code, message } => HostResultPayload {
273            call_id: call_id.to_string(),
274            output: serde_json::json!({}),
275            is_error: true,
276            error: Some(HostCallError {
277                code: protocol_error_code(&code),
278                message,
279                details: None,
280                retryable: None,
281            }),
282            chunk: None,
283        },
284    }
285}
286
287fn hostcall_outcome_to_protocol_result_with_trace(
288    payload: &HostCallPayload,
289    outcome: HostcallOutcome,
290) -> HostResultPayload {
291    match outcome {
292        HostcallOutcome::Success(output) => HostResultPayload {
293            call_id: payload.call_id.clone(),
294            output: protocol_normalize_output(output),
295            is_error: false,
296            error: None,
297            chunk: None,
298        },
299        HostcallOutcome::StreamChunk {
300            sequence,
301            chunk,
302            is_final,
303        } => HostResultPayload {
304            call_id: payload.call_id.clone(),
305            output: serde_json::json!({
306                "sequence": sequence,
307                "chunk": chunk,
308                "isFinal": is_final,
309            }),
310            is_error: false,
311            error: None,
312            chunk: Some(HostStreamChunk {
313                index: sequence,
314                is_last: is_final,
315                backpressure: None,
316            }),
317        },
318        HostcallOutcome::Error { code, message } => {
319            let details = Some(protocol_error_details(payload, &code, &message));
320            HostResultPayload {
321                call_id: payload.call_id.clone(),
322                output: serde_json::json!({}),
323                is_error: true,
324                error: Some(HostCallError {
325                    code: protocol_error_code(&code),
326                    message,
327                    details,
328                    retryable: None,
329                }),
330                chunk: None,
331            }
332        }
333    }
334}
335
336const DUAL_EXEC_SAMPLE_MODULUS_PPM: u32 = 1_000_000;
337const DUAL_EXEC_DEFAULT_SAMPLE_PPM: u32 = 25_000;
338const DUAL_EXEC_DEFAULT_DIVERGENCE_WINDOW: usize = 64;
339const DUAL_EXEC_DEFAULT_DIVERGENCE_BUDGET: usize = 3;
340const DUAL_EXEC_DEFAULT_ROLLBACK_REQUESTS: usize = 128;
341const DUAL_EXEC_DEFAULT_OVERHEAD_BUDGET_US: u64 = 1_500;
342const DUAL_EXEC_DEFAULT_OVERHEAD_BACKOFF_REQUESTS: usize = 32;
343
344#[derive(Debug, Clone, Copy)]
345struct DualExecOracleConfig {
346    sample_ppm: u32,
347    divergence_window: usize,
348    divergence_budget: usize,
349    rollback_requests: usize,
350    overhead_budget_us: u64,
351    overhead_backoff_requests: usize,
352}
353
354impl Default for DualExecOracleConfig {
355    fn default() -> Self {
356        Self::from_env()
357    }
358}
359
360impl DualExecOracleConfig {
361    fn from_env() -> Self {
362        let sample_ppm = std::env::var("PI_EXT_DUAL_EXEC_SAMPLE_PPM")
363            .ok()
364            .and_then(|raw| raw.trim().parse::<u32>().ok())
365            .unwrap_or(DUAL_EXEC_DEFAULT_SAMPLE_PPM)
366            .min(DUAL_EXEC_SAMPLE_MODULUS_PPM);
367        let divergence_window = std::env::var("PI_EXT_DUAL_EXEC_DIVERGENCE_WINDOW")
368            .ok()
369            .and_then(|raw| raw.trim().parse::<usize>().ok())
370            .unwrap_or(DUAL_EXEC_DEFAULT_DIVERGENCE_WINDOW)
371            .max(1);
372        let divergence_budget = std::env::var("PI_EXT_DUAL_EXEC_DIVERGENCE_BUDGET")
373            .ok()
374            .and_then(|raw| raw.trim().parse::<usize>().ok())
375            .unwrap_or(DUAL_EXEC_DEFAULT_DIVERGENCE_BUDGET)
376            .max(1);
377        let rollback_requests = std::env::var("PI_EXT_DUAL_EXEC_ROLLBACK_REQUESTS")
378            .ok()
379            .and_then(|raw| raw.trim().parse::<usize>().ok())
380            .unwrap_or(DUAL_EXEC_DEFAULT_ROLLBACK_REQUESTS)
381            .max(1);
382        let overhead_budget_us = std::env::var("PI_EXT_DUAL_EXEC_OVERHEAD_BUDGET_US")
383            .ok()
384            .and_then(|raw| raw.trim().parse::<u64>().ok())
385            .unwrap_or(DUAL_EXEC_DEFAULT_OVERHEAD_BUDGET_US)
386            .max(1);
387        let overhead_backoff_requests = std::env::var("PI_EXT_DUAL_EXEC_OVERHEAD_BACKOFF_REQUESTS")
388            .ok()
389            .and_then(|raw| raw.trim().parse::<usize>().ok())
390            .unwrap_or(DUAL_EXEC_DEFAULT_OVERHEAD_BACKOFF_REQUESTS)
391            .max(1);
392
393        Self {
394            sample_ppm,
395            divergence_window,
396            divergence_budget,
397            rollback_requests,
398            overhead_budget_us,
399            overhead_backoff_requests,
400        }
401    }
402}
403
404#[derive(Debug, Clone, Default)]
405struct DualExecOracleState {
406    sampled_total: u64,
407    matched_total: u64,
408    divergence_total: u64,
409    skipped_unsupported_total: u64,
410    skipped_overhead_total: u64,
411    divergence_window: VecDeque<bool>,
412    rollback_remaining: usize,
413    rollback_reason: Option<String>,
414    overhead_backoff_remaining: usize,
415}
416
417impl DualExecOracleState {
418    fn begin_request(&mut self) {
419        if self.rollback_remaining > 0 {
420            self.rollback_remaining = self.rollback_remaining.saturating_sub(1);
421            if self.rollback_remaining == 0 {
422                self.rollback_reason = None;
423            }
424        }
425        if self.overhead_backoff_remaining > 0 {
426            self.overhead_backoff_remaining = self.overhead_backoff_remaining.saturating_sub(1);
427        }
428    }
429
430    const fn rollback_active(&self) -> bool {
431        self.rollback_remaining > 0
432    }
433
434    const fn record_overhead_budget_exceeded(&mut self, config: DualExecOracleConfig) {
435        self.skipped_overhead_total = self.skipped_overhead_total.saturating_add(1);
436        self.overhead_backoff_remaining = config.overhead_backoff_requests;
437    }
438
439    fn record_sample(
440        &mut self,
441        divergent: bool,
442        config: DualExecOracleConfig,
443        extension_id: Option<&str>,
444    ) -> Option<String> {
445        self.sampled_total = self.sampled_total.saturating_add(1);
446        if divergent {
447            self.divergence_total = self.divergence_total.saturating_add(1);
448        } else {
449            self.matched_total = self.matched_total.saturating_add(1);
450        }
451        self.divergence_window.push_back(divergent);
452        while self.divergence_window.len() > config.divergence_window {
453            let _ = self.divergence_window.pop_front();
454        }
455        let divergence_count = self.divergence_window.iter().filter(|&&flag| flag).count();
456        if divergence_count >= config.divergence_budget {
457            self.rollback_remaining = config.rollback_requests;
458            let reason = format!(
459                "dual_exec_divergence_budget_exceeded:{divergence_count}/{window}:{scope}",
460                window = self.divergence_window.len(),
461                scope = extension_id.unwrap_or("global")
462            );
463            self.rollback_reason = Some(reason.clone());
464            return Some(reason);
465        }
466        None
467    }
468}
469
470#[derive(Debug, Clone, PartialEq, Eq)]
471struct DualExecOutcomeDiff {
472    reason: &'static str,
473    fast_fingerprint: String,
474    compat_fingerprint: String,
475}
476
477fn hostcall_value_fingerprint(value: &Value) -> String {
478    let mut hasher = sha2::Sha256::new();
479    hash_canonical_json(value, &mut hasher);
480    format!("{:x}", hasher.finalize())
481}
482
483fn hostcall_outcome_fingerprint(outcome: &HostcallOutcome) -> String {
484    match outcome {
485        HostcallOutcome::Success(output) => {
486            let hash = hostcall_value_fingerprint(output);
487            format!("success:{hash}")
488        }
489        HostcallOutcome::Error { code, message } => {
490            let hash = hostcall_value_fingerprint(&serde_json::json!({
491                "code": code,
492                "message": message,
493            }));
494            format!("error:{hash}")
495        }
496        HostcallOutcome::StreamChunk {
497            sequence,
498            chunk,
499            is_final,
500        } => {
501            let hash = hostcall_value_fingerprint(&serde_json::json!({
502                "sequence": sequence,
503                "chunk": chunk,
504                "isFinal": is_final,
505            }));
506            format!("stream:{hash}")
507        }
508    }
509}
510
511fn diff_hostcall_outcomes(
512    fast: &HostcallOutcome,
513    compat: &HostcallOutcome,
514) -> Option<DualExecOutcomeDiff> {
515    match (fast, compat) {
516        (HostcallOutcome::Success(a), HostcallOutcome::Success(b)) => {
517            let a_hash = hostcall_value_fingerprint(a);
518            let b_hash = hostcall_value_fingerprint(b);
519            if a_hash == b_hash {
520                None
521            } else {
522                Some(DualExecOutcomeDiff {
523                    reason: "success_output_mismatch",
524                    fast_fingerprint: format!("success:{a_hash}"),
525                    compat_fingerprint: format!("success:{b_hash}"),
526                })
527            }
528        }
529        (
530            HostcallOutcome::Error {
531                code: a_code,
532                message: a_message,
533            },
534            HostcallOutcome::Error {
535                code: b_code,
536                message: b_message,
537            },
538        ) => {
539            if a_code == b_code && a_message == b_message {
540                None
541            } else if a_code != b_code {
542                Some(DualExecOutcomeDiff {
543                    reason: "error_code_mismatch",
544                    fast_fingerprint: hostcall_outcome_fingerprint(fast),
545                    compat_fingerprint: hostcall_outcome_fingerprint(compat),
546                })
547            } else {
548                Some(DualExecOutcomeDiff {
549                    reason: "error_message_mismatch",
550                    fast_fingerprint: hostcall_outcome_fingerprint(fast),
551                    compat_fingerprint: hostcall_outcome_fingerprint(compat),
552                })
553            }
554        }
555        (
556            HostcallOutcome::StreamChunk {
557                sequence: a_seq,
558                chunk: a_chunk,
559                is_final: a_final,
560            },
561            HostcallOutcome::StreamChunk {
562                sequence: b_seq,
563                chunk: b_chunk,
564                is_final: b_final,
565            },
566        ) => {
567            if a_seq == b_seq && a_chunk == b_chunk && a_final == b_final {
568                None
569            } else if a_seq != b_seq {
570                Some(DualExecOutcomeDiff {
571                    reason: "stream_sequence_mismatch",
572                    fast_fingerprint: hostcall_outcome_fingerprint(fast),
573                    compat_fingerprint: hostcall_outcome_fingerprint(compat),
574                })
575            } else if a_final != b_final {
576                Some(DualExecOutcomeDiff {
577                    reason: "stream_finality_mismatch",
578                    fast_fingerprint: hostcall_outcome_fingerprint(fast),
579                    compat_fingerprint: hostcall_outcome_fingerprint(compat),
580                })
581            } else {
582                Some(DualExecOutcomeDiff {
583                    reason: "stream_chunk_mismatch",
584                    fast_fingerprint: hostcall_outcome_fingerprint(fast),
585                    compat_fingerprint: hostcall_outcome_fingerprint(compat),
586                })
587            }
588        }
589        _ => Some(DualExecOutcomeDiff {
590            reason: "outcome_variant_mismatch",
591            fast_fingerprint: hostcall_outcome_fingerprint(fast),
592            compat_fingerprint: hostcall_outcome_fingerprint(compat),
593        }),
594    }
595}
596
597fn should_sample_shadow_dual_exec(request: &HostcallRequest, sample_ppm: u32) -> bool {
598    if sample_ppm == 0 {
599        return false;
600    }
601    if sample_ppm >= DUAL_EXEC_SAMPLE_MODULUS_PPM {
602        return true;
603    }
604    let bucket = shadow_sampling_bucket(request) % DUAL_EXEC_SAMPLE_MODULUS_PPM;
605    bucket < sample_ppm
606}
607
608#[inline]
609fn fnv1a64_update(mut hash: u64, bytes: &[u8]) -> u64 {
610    const FNV1A_PRIME: u64 = 1_099_511_628_211;
611    for &byte in bytes {
612        hash ^= u64::from(byte);
613        hash = hash.wrapping_mul(FNV1A_PRIME);
614    }
615    hash
616}
617
618#[inline]
619fn shadow_sampling_bucket(request: &HostcallRequest) -> u32 {
620    // Deterministic, allocation-free mixing for high-frequency sampling checks.
621    const FNV1A_OFFSET_BASIS: u64 = 14_695_981_039_346_656_037;
622    let mut hash = FNV1A_OFFSET_BASIS;
623    hash = fnv1a64_update(hash, request.call_id.as_bytes());
624    hash = fnv1a64_update(hash, &[0xFF]);
625    hash = fnv1a64_update(hash, &request.trace_id.to_le_bytes());
626    if let Some(extension_id) = request.extension_id.as_deref() {
627        hash = fnv1a64_update(hash, &[0xFE]);
628        hash = fnv1a64_update(hash, extension_id.as_bytes());
629    }
630
631    // Final avalanche to improve low-bit dispersion before modulus.
632    hash ^= hash >> 33;
633    hash = hash.wrapping_mul(0xff51_afd7_ed55_8ccd);
634    hash ^= hash >> 33;
635    hash = hash.wrapping_mul(0xc4ce_b9fe_1a85_ec53);
636    hash ^= hash >> 33;
637
638    let bytes = hash.to_le_bytes();
639    let low = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
640    let high = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
641    low ^ high
642}
643
644fn normalized_shadow_op(op: &str) -> String {
645    let trimmed = op.trim();
646    let mut normalized = String::with_capacity(trimmed.len());
647    for ch in trimmed.chars() {
648        if ch != '_' {
649            normalized.push(ch.to_ascii_lowercase());
650        }
651    }
652    normalized
653}
654
655#[inline]
656fn with_folded_ascii_alnum_token<T>(token: &str, f: impl FnOnce(&[u8]) -> T) -> T {
657    const INLINE_CAP: usize = 64;
658    let mut inline = [0_u8; INLINE_CAP];
659    let mut inline_len = 0_usize;
660    let mut heap: Option<Vec<u8>> = None;
661
662    for byte in token.trim().bytes() {
663        if !byte.is_ascii_alphanumeric() {
664            continue;
665        }
666        let folded = byte.to_ascii_lowercase();
667        if let Some(buf) = heap.as_mut() {
668            buf.push(folded);
669            continue;
670        }
671        if inline_len < INLINE_CAP {
672            inline[inline_len] = folded;
673            inline_len += 1;
674        } else {
675            let mut buf = Vec::with_capacity(token.len());
676            buf.extend_from_slice(&inline[..inline_len]);
677            buf.push(folded);
678            heap = Some(buf);
679        }
680    }
681
682    if let Some(buf) = heap {
683        f(buf.as_slice())
684    } else {
685        f(&inline[..inline_len])
686    }
687}
688
689fn shadow_safe_session_op(op: &str) -> bool {
690    with_folded_ascii_alnum_token(op, |folded| {
691        matches!(
692            folded,
693            b"getstate"
694                | b"getmessages"
695                | b"getentries"
696                | b"getbranch"
697                | b"getfile"
698                | b"getname"
699                | b"getmodel"
700                | b"getthinkinglevel"
701                | b"getlabel"
702                | b"getlabels"
703                | b"getallsessions"
704        )
705    })
706}
707
708fn shadow_safe_events_op(op: &str) -> bool {
709    with_folded_ascii_alnum_token(op, |folded| {
710        matches!(
711            folded,
712            b"getactivetools"
713                | b"getalltools"
714                | b"getmodel"
715                | b"getthinkinglevel"
716                | b"getflag"
717                | b"listflags"
718        )
719    })
720}
721
722fn shadow_safe_tool(name: &str) -> bool {
723    let name = name.trim();
724    name.eq_ignore_ascii_case("read")
725        || name.eq_ignore_ascii_case("grep")
726        || name.eq_ignore_ascii_case("find")
727        || name.eq_ignore_ascii_case("ls")
728}
729
730fn is_shadow_safe_request(request: &HostcallRequest) -> bool {
731    match &request.kind {
732        HostcallKind::Session { op } => shadow_safe_session_op(op),
733        HostcallKind::Events { op } => shadow_safe_events_op(op),
734        HostcallKind::Tool { name } => shadow_safe_tool(name),
735        HostcallKind::Http
736        | HostcallKind::Exec { .. }
737        | HostcallKind::Ui { .. }
738        | HostcallKind::Log => false,
739    }
740}
741
742fn parse_env_bool(name: &str, default: bool) -> bool {
743    std::env::var(name).ok().map_or(default, |raw| {
744        match raw.trim().to_ascii_lowercase().as_str() {
745            "1" | "true" | "yes" | "on" | "enabled" => true,
746            "0" | "false" | "no" | "off" | "disabled" => false,
747            _ => default,
748        }
749    })
750}
751
752fn io_uring_lane_policy_from_env() -> IoUringLanePolicyConfig {
753    let default = IoUringLanePolicyConfig::conservative();
754    let max_queue_depth = std::env::var("PI_EXT_IO_URING_MAX_QUEUE_DEPTH")
755        .ok()
756        .and_then(|raw| raw.trim().parse::<usize>().ok())
757        .unwrap_or(default.max_queue_depth)
758        .max(1);
759
760    IoUringLanePolicyConfig {
761        enabled: parse_env_bool("PI_EXT_IO_URING_ENABLED", default.enabled),
762        ring_available: parse_env_bool("PI_EXT_IO_URING_RING_AVAILABLE", default.ring_available),
763        max_queue_depth,
764        allow_filesystem: parse_env_bool(
765            "PI_EXT_IO_URING_ALLOW_FILESYSTEM",
766            default.allow_filesystem,
767        ),
768        allow_network: parse_env_bool("PI_EXT_IO_URING_ALLOW_NETWORK", default.allow_network),
769    }
770}
771
772fn io_uring_force_compat_from_env() -> bool {
773    parse_env_bool("PI_EXT_IO_URING_FORCE_COMPAT", false)
774}
775
776fn hostcall_io_hint(kind: &HostcallKind) -> HostcallIoHint {
777    match kind {
778        HostcallKind::Http => HostcallIoHint::IoHeavy,
779        HostcallKind::Tool { name } => {
780            let name = name.trim();
781            if name.eq_ignore_ascii_case("read")
782                || name.eq_ignore_ascii_case("write")
783                || name.eq_ignore_ascii_case("edit")
784                || name.eq_ignore_ascii_case("grep")
785                || name.eq_ignore_ascii_case("find")
786                || name.eq_ignore_ascii_case("ls")
787            {
788                HostcallIoHint::IoHeavy
789            } else if name.eq_ignore_ascii_case("bash") {
790                HostcallIoHint::CpuBound
791            } else {
792                HostcallIoHint::Unknown
793            }
794        }
795        HostcallKind::Session { op } => {
796            let lower = op.trim().to_ascii_lowercase();
797            if lower.contains("save")
798                || lower.contains("append")
799                || lower.contains("write")
800                || lower.contains("export")
801                || lower.contains("import")
802            {
803                HostcallIoHint::IoHeavy
804            } else {
805                HostcallIoHint::Unknown
806            }
807        }
808        HostcallKind::Exec { .. }
809        | HostcallKind::Ui { .. }
810        | HostcallKind::Events { .. }
811        | HostcallKind::Log => HostcallIoHint::CpuBound,
812    }
813}
814
815const fn hostcall_io_hint_label(io_hint: HostcallIoHint) -> &'static str {
816    match io_hint {
817        HostcallIoHint::Unknown => "unknown",
818        HostcallIoHint::IoHeavy => "io_heavy",
819        HostcallIoHint::CpuBound => "cpu_bound",
820    }
821}
822
823const fn hostcall_capability_label(capability: HostcallCapabilityClass) -> &'static str {
824    match capability {
825        HostcallCapabilityClass::Filesystem => "filesystem",
826        HostcallCapabilityClass::Network => "network",
827        HostcallCapabilityClass::Execution => "execution",
828        HostcallCapabilityClass::Session => "session",
829        HostcallCapabilityClass::Events => "events",
830        HostcallCapabilityClass::Environment => "environment",
831        HostcallCapabilityClass::Tool => "tool",
832        HostcallCapabilityClass::Ui => "ui",
833        HostcallCapabilityClass::Telemetry => "telemetry",
834        HostcallCapabilityClass::Unknown => "unknown",
835    }
836}
837
838#[derive(Debug, Clone, Copy, PartialEq, Eq)]
839enum IoUringBridgeState {
840    DelegatedFastPath,
841    CancelledBeforeDispatch,
842    CancelledAfterDispatch,
843}
844
845impl IoUringBridgeState {
846    const fn as_str(self) -> &'static str {
847        match self {
848            Self::DelegatedFastPath => "delegated_fast_path",
849            Self::CancelledBeforeDispatch => "cancelled_before_dispatch",
850            Self::CancelledAfterDispatch => "cancelled_after_dispatch",
851        }
852    }
853}
854
855#[derive(Debug, Clone)]
856struct IoUringBridgeDispatch {
857    outcome: HostcallOutcome,
858    state: IoUringBridgeState,
859    fallback_reason: Option<&'static str>,
860}
861
862fn clone_payload_object_without_key(
863    map: &serde_json::Map<String, Value>,
864    reserved_key: &str,
865) -> serde_json::Map<String, Value> {
866    let mut out = serde_json::Map::with_capacity(map.len());
867    for (key, value) in map {
868        if key == reserved_key {
869            continue;
870        }
871        out.insert(key.clone(), value.clone());
872    }
873    out
874}
875
876fn clone_payload_object_without_two_keys(
877    map: &serde_json::Map<String, Value>,
878    reserved_a: &str,
879    reserved_b: &str,
880) -> serde_json::Map<String, Value> {
881    let mut out = serde_json::Map::with_capacity(map.len());
882    for (key, value) in map {
883        if key == reserved_a || key == reserved_b {
884            continue;
885        }
886        out.insert(key.clone(), value.clone());
887    }
888    out
889}
890
891fn protocol_params_from_request(request: &HostcallRequest) -> Value {
892    match &request.kind {
893        HostcallKind::Tool { name } => {
894            let mut object = serde_json::Map::with_capacity(2);
895            object.insert("name".to_string(), Value::String(name.clone()));
896            object.insert("input".to_string(), request.payload.clone());
897            Value::Object(object)
898        }
899        HostcallKind::Exec { cmd } => {
900            let mut object = match &request.payload {
901                Value::Object(map) => clone_payload_object_without_two_keys(map, "command", "cmd"),
902                Value::Null => serde_json::Map::new(),
903                other => {
904                    let mut out = serde_json::Map::new();
905                    out.insert("payload".to_string(), other.clone());
906                    out
907                }
908            };
909            object.insert("cmd".to_string(), Value::String(cmd.clone()));
910            Value::Object(object)
911        }
912        HostcallKind::Http | HostcallKind::Log => request.payload.clone(),
913        HostcallKind::Session { op } | HostcallKind::Ui { op } | HostcallKind::Events { op } => {
914            let mut object = match &request.payload {
915                Value::Object(map) => clone_payload_object_without_key(map, "op"),
916                Value::Null => serde_json::Map::new(),
917                other => {
918                    let mut out = serde_json::Map::new();
919                    out.insert("payload".to_string(), other.clone());
920                    out
921                }
922            };
923            object.insert("op".to_string(), Value::String(op.clone()));
924            Value::Object(object)
925        }
926    }
927}
928
929fn dual_exec_forensic_bundle(
930    request: &HostcallRequest,
931    diff: &DualExecOutcomeDiff,
932    rollback_reason: Option<&str>,
933    shadow_elapsed_us: f64,
934) -> Value {
935    serde_json::json!({
936        "call_trace": {
937            "call_id": request.call_id,
938            "trace_id": request.trace_id,
939            "extension_id": request.extension_id,
940            "method": request.method(),
941            "params_hash": request.params_hash(),
942            "capability": request.required_capability(),
943        },
944        "lane_decision": {
945            "fast_lane": "fast",
946            "compat_lane": "compat_shadow",
947        },
948        "diff": {
949            "reason": diff.reason,
950            "fast_fingerprint": diff.fast_fingerprint,
951            "compat_fingerprint": diff.compat_fingerprint,
952            "shadow_elapsed_us": shadow_elapsed_us,
953        },
954        "rollback": {
955            "triggered": rollback_reason.is_some(),
956            "reason": rollback_reason,
957        }
958    })
959}
960
961const REGIME_MIN_SAMPLES: usize = 24;
962const REGIME_CUSUM_DRIFT: f64 = 0.03;
963const REGIME_CUSUM_THRESHOLD: f64 = 1.6;
964const REGIME_BOCPD_HAZARD: f64 = 0.08;
965const REGIME_POSTERIOR_DECAY: f64 = 0.92;
966const REGIME_POSTERIOR_THRESHOLD: f64 = 0.45;
967const REGIME_COOLDOWN_OBSERVATIONS: usize = 32;
968const REGIME_CONFIRMATION_STREAK: usize = 2;
969const REGIME_FALLBACK_QUEUE_DEPTH: f64 = 1.0;
970const REGIME_FALLBACK_SERVICE_US: f64 = 1_200.0;
971const REGIME_VARIANCE_FLOOR: f64 = 1e-6;
972const ROLLOUT_ALPHA: f64 = 0.05;
973const ROLLOUT_HIGH_STRATUM_QUEUE_MIN: f64 = 8.0;
974const ROLLOUT_HIGH_STRATUM_SERVICE_US_MIN: f64 = 4_500.0;
975const ROLLOUT_LOW_STRATUM_QUEUE_MAX: f64 = 2.0;
976const ROLLOUT_LOW_STRATUM_SERVICE_US_MAX: f64 = 1_800.0;
977const ROLLOUT_PROMOTE_SCORE_THRESHOLD: f64 = 1.25;
978const ROLLOUT_ROLLBACK_SCORE_THRESHOLD: f64 = 0.70;
979const ROLLOUT_MIN_STRATUM_SAMPLES: usize = 10;
980const ROLLOUT_MIN_TOTAL_SAMPLES: usize = 30;
981const ROLLOUT_LOG_E_CLAMP: f64 = 120.0;
982const ROLLOUT_LR_NULL: f64 = 0.35;
983const ROLLOUT_LR_ALT: f64 = 0.65;
984const ROLLOUT_FALSE_PROMOTE_LOSS: f64 = 28.0;
985const ROLLOUT_FALSE_ROLLBACK_LOSS: f64 = 12.0;
986const ROLLOUT_HOLD_OPPORTUNITY_LOSS: f64 = 10.0;
987
988#[derive(Debug, Clone, Copy, PartialEq, Eq)]
989enum RegimeAdaptationMode {
990    SequentialFastPath,
991    InterleavedBatching,
992}
993
994impl RegimeAdaptationMode {
995    const fn as_str(self) -> &'static str {
996        match self {
997            Self::SequentialFastPath => "sequential_fast_path",
998            Self::InterleavedBatching => "interleaved_batching",
999        }
1000    }
1001}
1002
1003#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1004enum RegimeTransition {
1005    EnterInterleavedBatching,
1006    ReturnToSequentialFastPath,
1007}
1008
1009impl RegimeTransition {
1010    const fn as_str(self) -> &'static str {
1011        match self {
1012            Self::EnterInterleavedBatching => "enter_interleaved_batching",
1013            Self::ReturnToSequentialFastPath => "return_to_sequential_fast_path",
1014        }
1015    }
1016}
1017
1018#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1019enum RolloutGateAction {
1020    Hold,
1021    PromoteInterleaved,
1022    RollbackSequential,
1023}
1024
1025impl RolloutGateAction {
1026    const fn as_str(self) -> &'static str {
1027        match self {
1028            Self::Hold => "hold",
1029            Self::PromoteInterleaved => "promote_interleaved",
1030            Self::RollbackSequential => "rollback_sequential",
1031        }
1032    }
1033}
1034
1035#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1036enum RolloutEvidenceStratum {
1037    HighContention,
1038    LowContention,
1039    Mixed,
1040}
1041
1042impl RolloutEvidenceStratum {
1043    const fn as_str(self) -> &'static str {
1044        match self {
1045            Self::HighContention => "high_contention",
1046            Self::LowContention => "low_contention",
1047            Self::Mixed => "mixed",
1048        }
1049    }
1050}
1051
1052#[derive(Debug, Clone, Copy)]
1053struct RolloutExpectedLoss {
1054    hold: f64,
1055    promote: f64,
1056    rollback: f64,
1057}
1058
1059#[derive(Debug, Clone, Copy)]
1060struct RolloutGateDecision {
1061    action: RolloutGateAction,
1062    expected_loss: RolloutExpectedLoss,
1063    promote_posterior: f64,
1064    rollback_posterior: f64,
1065    promote_e_process: f64,
1066    rollback_e_process: f64,
1067    evidence_threshold: f64,
1068    total_samples: usize,
1069    high_samples: usize,
1070    low_samples: usize,
1071    coverage_ready: bool,
1072    blocked_underpowered: bool,
1073    blocked_cherry_picked: bool,
1074}
1075
1076#[derive(Debug, Clone)]
1077struct RolloutGateState {
1078    total_samples: usize,
1079    high_samples: usize,
1080    low_samples: usize,
1081    promote_alpha: f64,
1082    promote_beta: f64,
1083    rollback_alpha: f64,
1084    rollback_beta: f64,
1085    promote_log_e: f64,
1086    rollback_log_e: f64,
1087}
1088
1089impl Default for RolloutGateState {
1090    fn default() -> Self {
1091        Self {
1092            total_samples: 0,
1093            high_samples: 0,
1094            low_samples: 0,
1095            promote_alpha: 1.0,
1096            promote_beta: 1.0,
1097            rollback_alpha: 1.0,
1098            rollback_beta: 1.0,
1099            promote_log_e: 0.0,
1100            rollback_log_e: 0.0,
1101        }
1102    }
1103}
1104
1105#[derive(Debug, Clone, Copy)]
1106struct RegimeSignal {
1107    queue_depth: f64,
1108    service_time_us: f64,
1109    opcode_entropy: f64,
1110    llc_miss_rate: f64,
1111}
1112
1113impl RegimeSignal {
1114    fn composite_score(self) -> f64 {
1115        let queue_component = (self.queue_depth / 32.0).min(4.0);
1116        let service_component = (self.service_time_us / 5_000.0).min(4.0);
1117        let entropy_component = (self.opcode_entropy / 4.0).min(2.0);
1118        let llc_component = self.llc_miss_rate.clamp(0.0, 1.0) * 2.0;
1119        0.15f64.mul_add(
1120            llc_component,
1121            0.15f64.mul_add(
1122                entropy_component,
1123                0.35f64.mul_add(queue_component, 0.35 * service_component),
1124            ),
1125        )
1126    }
1127}
1128
1129#[derive(Debug, Clone, Copy)]
1130#[allow(clippy::struct_excessive_bools)]
1131struct RegimeObservation {
1132    score: f64,
1133    mean: f64,
1134    stddev: f64,
1135    upper_cusum: f64,
1136    lower_cusum: f64,
1137    change_posterior: f64,
1138    transition: Option<RegimeTransition>,
1139    mode: RegimeAdaptationMode,
1140    fallback_triggered: bool,
1141    rollout_action: RolloutGateAction,
1142    rollout_stratum: RolloutEvidenceStratum,
1143    rollout_expected_loss: RolloutExpectedLoss,
1144    rollout_promote_posterior: f64,
1145    rollout_rollback_posterior: f64,
1146    rollout_promote_e_process: f64,
1147    rollout_rollback_e_process: f64,
1148    rollout_evidence_threshold: f64,
1149    rollout_total_samples: usize,
1150    rollout_high_samples: usize,
1151    rollout_low_samples: usize,
1152    rollout_coverage_ready: bool,
1153    rollout_blocked_underpowered: bool,
1154    rollout_blocked_cherry_picked: bool,
1155}
1156
1157#[derive(Debug, Clone)]
1158struct RegimeShiftDetector {
1159    sample_count: usize,
1160    mean: f64,
1161    m2: f64,
1162    upper_cusum: f64,
1163    lower_cusum: f64,
1164    change_posterior: f64,
1165    cooldown_remaining: usize,
1166    confirmation_streak: usize,
1167    mode: RegimeAdaptationMode,
1168    rollout_gate: RolloutGateState,
1169}
1170
1171impl Default for RegimeShiftDetector {
1172    fn default() -> Self {
1173        Self {
1174            sample_count: 0,
1175            mean: 0.0,
1176            m2: 0.0,
1177            upper_cusum: 0.0,
1178            lower_cusum: 0.0,
1179            change_posterior: 0.0,
1180            cooldown_remaining: 0,
1181            confirmation_streak: 0,
1182            mode: RegimeAdaptationMode::SequentialFastPath,
1183            rollout_gate: RolloutGateState::default(),
1184        }
1185    }
1186}
1187
1188impl RegimeShiftDetector {
1189    const fn current_mode(&self) -> RegimeAdaptationMode {
1190        self.mode
1191    }
1192
1193    #[allow(clippy::too_many_lines)]
1194    fn observe(&mut self, signal: RegimeSignal) -> RegimeObservation {
1195        let score = signal.composite_score();
1196        let baseline_mean = self.mean;
1197        let baseline_stddev = self.variance().sqrt().max(REGIME_VARIANCE_FLOOR);
1198        let deviation = if self.sample_count > 1 {
1199            score - baseline_mean
1200        } else {
1201            0.0
1202        };
1203
1204        self.upper_cusum = (self.upper_cusum + deviation - REGIME_CUSUM_DRIFT).max(0.0);
1205        self.lower_cusum = (self.lower_cusum + deviation + REGIME_CUSUM_DRIFT).min(0.0);
1206
1207        let z_score = if baseline_stddev > REGIME_VARIANCE_FLOOR {
1208            deviation / baseline_stddev
1209        } else {
1210            0.0
1211        };
1212        let evidence = (z_score.abs() - 0.8).max(0.0);
1213        let change_likelihood = 1.0 - (-evidence).exp();
1214        self.change_posterior = self
1215            .change_posterior
1216            .mul_add(
1217                REGIME_POSTERIOR_DECAY,
1218                REGIME_BOCPD_HAZARD * change_likelihood,
1219            )
1220            .clamp(0.0, 1.0);
1221
1222        let cusum_triggered = self.upper_cusum >= REGIME_CUSUM_THRESHOLD
1223            || self.lower_cusum <= -REGIME_CUSUM_THRESHOLD;
1224        let posterior_triggered = self.change_posterior >= REGIME_POSTERIOR_THRESHOLD;
1225        let candidate_shift =
1226            self.sample_count >= REGIME_MIN_SAMPLES && cusum_triggered && posterior_triggered;
1227        let direction_is_up = self.upper_cusum >= -self.lower_cusum;
1228        let rollout_stratum = rollout_evidence_stratum(signal);
1229        let rollout_decision = self.rollout_gate.observe(
1230            score,
1231            rollout_stratum,
1232            self.mode,
1233            candidate_shift,
1234            direction_is_up,
1235        );
1236
1237        let mut transition = None;
1238        let mut fallback_triggered = false;
1239
1240        if self.cooldown_remaining > 0 {
1241            self.cooldown_remaining = self.cooldown_remaining.saturating_sub(1);
1242            self.confirmation_streak = 0;
1243        } else {
1244            let desired_mode = match rollout_decision.action {
1245                RolloutGateAction::PromoteInterleaved => {
1246                    Some(RegimeAdaptationMode::InterleavedBatching)
1247                }
1248                RolloutGateAction::RollbackSequential => {
1249                    Some(RegimeAdaptationMode::SequentialFastPath)
1250                }
1251                RolloutGateAction::Hold => None,
1252            };
1253            if let Some(desired_mode) = desired_mode {
1254                if desired_mode == self.mode {
1255                    self.confirmation_streak = 0;
1256                } else {
1257                    self.confirmation_streak = self.confirmation_streak.saturating_add(1);
1258                    if self.confirmation_streak >= REGIME_CONFIRMATION_STREAK {
1259                        self.mode = desired_mode;
1260                        transition = Some(match desired_mode {
1261                            RegimeAdaptationMode::InterleavedBatching => {
1262                                RegimeTransition::EnterInterleavedBatching
1263                            }
1264                            RegimeAdaptationMode::SequentialFastPath => {
1265                                RegimeTransition::ReturnToSequentialFastPath
1266                            }
1267                        });
1268                        self.cooldown_remaining = REGIME_COOLDOWN_OBSERVATIONS;
1269                        self.upper_cusum = 0.0;
1270                        self.lower_cusum = 0.0;
1271                        self.change_posterior = self.change_posterior.min(0.5);
1272                        self.confirmation_streak = 0;
1273                    }
1274                }
1275            } else {
1276                self.confirmation_streak = 0;
1277            }
1278        }
1279
1280        if self.mode == RegimeAdaptationMode::InterleavedBatching
1281            && signal.queue_depth <= REGIME_FALLBACK_QUEUE_DEPTH
1282            && signal.service_time_us <= REGIME_FALLBACK_SERVICE_US
1283        {
1284            self.mode = RegimeAdaptationMode::SequentialFastPath;
1285            transition = Some(RegimeTransition::ReturnToSequentialFastPath);
1286            fallback_triggered = true;
1287            self.cooldown_remaining = REGIME_COOLDOWN_OBSERVATIONS / 2;
1288            self.upper_cusum = 0.0;
1289            self.lower_cusum = 0.0;
1290            self.change_posterior = self.change_posterior.min(0.25);
1291            self.confirmation_streak = 0;
1292        }
1293
1294        self.sample_count = self.sample_count.saturating_add(1);
1295        if self.sample_count == 1 {
1296            self.mean = score;
1297            self.m2 = 0.0;
1298        } else {
1299            let count_f64 = f64::from(u32::try_from(self.sample_count).unwrap_or(u32::MAX));
1300            let delta = score - self.mean;
1301            self.mean += delta / count_f64;
1302            let delta2 = score - self.mean;
1303            self.m2 += delta * delta2;
1304        }
1305
1306        RegimeObservation {
1307            score,
1308            mean: self.mean,
1309            stddev: self.variance().sqrt().max(REGIME_VARIANCE_FLOOR),
1310            upper_cusum: self.upper_cusum,
1311            lower_cusum: self.lower_cusum,
1312            change_posterior: self.change_posterior,
1313            transition,
1314            mode: self.mode,
1315            fallback_triggered,
1316            rollout_action: rollout_decision.action,
1317            rollout_stratum,
1318            rollout_expected_loss: rollout_decision.expected_loss,
1319            rollout_promote_posterior: rollout_decision.promote_posterior,
1320            rollout_rollback_posterior: rollout_decision.rollback_posterior,
1321            rollout_promote_e_process: rollout_decision.promote_e_process,
1322            rollout_rollback_e_process: rollout_decision.rollback_e_process,
1323            rollout_evidence_threshold: rollout_decision.evidence_threshold,
1324            rollout_total_samples: rollout_decision.total_samples,
1325            rollout_high_samples: rollout_decision.high_samples,
1326            rollout_low_samples: rollout_decision.low_samples,
1327            rollout_coverage_ready: rollout_decision.coverage_ready,
1328            rollout_blocked_underpowered: rollout_decision.blocked_underpowered,
1329            rollout_blocked_cherry_picked: rollout_decision.blocked_cherry_picked,
1330        }
1331    }
1332
1333    fn variance(&self) -> f64 {
1334        if self.sample_count < 2 {
1335            REGIME_VARIANCE_FLOOR
1336        } else {
1337            let denom =
1338                f64::from(u32::try_from(self.sample_count.saturating_sub(1)).unwrap_or(u32::MAX));
1339            (self.m2 / denom).max(REGIME_VARIANCE_FLOOR)
1340        }
1341    }
1342}
1343
1344impl RolloutGateState {
1345    fn observe(
1346        &mut self,
1347        score: f64,
1348        stratum: RolloutEvidenceStratum,
1349        mode: RegimeAdaptationMode,
1350        _candidate_shift: bool,
1351        _direction_is_up: bool,
1352    ) -> RolloutGateDecision {
1353        self.total_samples = self.total_samples.saturating_add(1);
1354        match stratum {
1355            RolloutEvidenceStratum::HighContention => {
1356                self.high_samples = self.high_samples.saturating_add(1);
1357            }
1358            RolloutEvidenceStratum::LowContention => {
1359                self.low_samples = self.low_samples.saturating_add(1);
1360            }
1361            RolloutEvidenceStratum::Mixed => {}
1362        }
1363
1364        match stratum {
1365            RolloutEvidenceStratum::HighContention => {
1366                let promote_signal = score >= ROLLOUT_PROMOTE_SCORE_THRESHOLD;
1367                if promote_signal {
1368                    self.promote_alpha += 1.0;
1369                } else {
1370                    self.promote_beta += 1.0;
1371                }
1372                self.promote_log_e = (self.promote_log_e
1373                    + bernoulli_log_likelihood_ratio(
1374                        promote_signal,
1375                        ROLLOUT_LR_NULL,
1376                        ROLLOUT_LR_ALT,
1377                    ))
1378                .clamp(-ROLLOUT_LOG_E_CLAMP, ROLLOUT_LOG_E_CLAMP);
1379            }
1380            RolloutEvidenceStratum::LowContention => {
1381                let rollback_signal = score <= ROLLOUT_ROLLBACK_SCORE_THRESHOLD;
1382                if rollback_signal {
1383                    self.rollback_alpha += 1.0;
1384                } else {
1385                    self.rollback_beta += 1.0;
1386                }
1387                self.rollback_log_e = (self.rollback_log_e
1388                    + bernoulli_log_likelihood_ratio(
1389                        rollback_signal,
1390                        ROLLOUT_LR_NULL,
1391                        ROLLOUT_LR_ALT,
1392                    ))
1393                .clamp(-ROLLOUT_LOG_E_CLAMP, ROLLOUT_LOG_E_CLAMP);
1394            }
1395            RolloutEvidenceStratum::Mixed => {}
1396        }
1397
1398        let promote_posterior = self.promote_alpha / (self.promote_alpha + self.promote_beta);
1399        let rollback_posterior = self.rollback_alpha / (self.rollback_alpha + self.rollback_beta);
1400        let promote_e_process = self.promote_log_e.exp();
1401        let rollback_e_process = self.rollback_log_e.exp();
1402        let evidence_threshold = 1.0 / ROLLOUT_ALPHA;
1403        let expected_loss = rollout_expected_loss(mode, promote_posterior, rollback_posterior);
1404
1405        let blocked_underpowered = self.total_samples < ROLLOUT_MIN_TOTAL_SAMPLES;
1406        let blocked_cherry_picked = self.high_samples < ROLLOUT_MIN_STRATUM_SAMPLES
1407            || self.low_samples < ROLLOUT_MIN_STRATUM_SAMPLES;
1408        let coverage_ready = !blocked_underpowered && !blocked_cherry_picked;
1409
1410        let promote_ready = coverage_ready
1411            && mode == RegimeAdaptationMode::SequentialFastPath
1412            && promote_e_process >= evidence_threshold
1413            && expected_loss.promote < expected_loss.hold;
1414
1415        let rollback_ready = coverage_ready
1416            && mode == RegimeAdaptationMode::InterleavedBatching
1417            && rollback_e_process >= evidence_threshold
1418            && expected_loss.rollback < expected_loss.hold;
1419
1420        let action = if promote_ready {
1421            RolloutGateAction::PromoteInterleaved
1422        } else if rollback_ready {
1423            RolloutGateAction::RollbackSequential
1424        } else {
1425            RolloutGateAction::Hold
1426        };
1427
1428        RolloutGateDecision {
1429            action,
1430            expected_loss,
1431            promote_posterior,
1432            rollback_posterior,
1433            promote_e_process,
1434            rollback_e_process,
1435            evidence_threshold,
1436            total_samples: self.total_samples,
1437            high_samples: self.high_samples,
1438            low_samples: self.low_samples,
1439            coverage_ready,
1440            blocked_underpowered,
1441            blocked_cherry_picked,
1442        }
1443    }
1444}
1445
1446fn rollout_evidence_stratum(signal: RegimeSignal) -> RolloutEvidenceStratum {
1447    if signal.queue_depth >= ROLLOUT_HIGH_STRATUM_QUEUE_MIN
1448        || signal.service_time_us >= ROLLOUT_HIGH_STRATUM_SERVICE_US_MIN
1449    {
1450        RolloutEvidenceStratum::HighContention
1451    } else if signal.queue_depth <= ROLLOUT_LOW_STRATUM_QUEUE_MAX
1452        && signal.service_time_us <= ROLLOUT_LOW_STRATUM_SERVICE_US_MAX
1453    {
1454        RolloutEvidenceStratum::LowContention
1455    } else {
1456        RolloutEvidenceStratum::Mixed
1457    }
1458}
1459
1460fn bernoulli_log_likelihood_ratio(observed_true: bool, p0: f64, p1: f64) -> f64 {
1461    let p0 = p0.clamp(1e-6, 1.0 - 1e-6);
1462    let p1 = p1.clamp(1e-6, 1.0 - 1e-6);
1463    if observed_true {
1464        f64::ln(p1 / p0)
1465    } else {
1466        f64::ln((1.0 - p1) / (1.0 - p0))
1467    }
1468}
1469
1470fn rollout_expected_loss(
1471    mode: RegimeAdaptationMode,
1472    promote_posterior: f64,
1473    rollback_posterior: f64,
1474) -> RolloutExpectedLoss {
1475    let hold = ROLLOUT_HOLD_OPPORTUNITY_LOSS
1476        .mul_add(promote_posterior, 3.0f64.mul_add(rollback_posterior, 1.0));
1477    let promote = match mode {
1478        RegimeAdaptationMode::SequentialFastPath => {
1479            ROLLOUT_FALSE_PROMOTE_LOSS.mul_add(1.0 - promote_posterior, 2.0 * rollback_posterior)
1480        }
1481        RegimeAdaptationMode::InterleavedBatching => ROLLOUT_FALSE_PROMOTE_LOSS
1482            .mul_add(1.0 - promote_posterior, ROLLOUT_HOLD_OPPORTUNITY_LOSS),
1483    };
1484    let rollback = match mode {
1485        RegimeAdaptationMode::SequentialFastPath => ROLLOUT_FALSE_ROLLBACK_LOSS
1486            .mul_add(1.0 - rollback_posterior, ROLLOUT_HOLD_OPPORTUNITY_LOSS),
1487        RegimeAdaptationMode::InterleavedBatching => {
1488            ROLLOUT_FALSE_ROLLBACK_LOSS.mul_add(1.0 - rollback_posterior, 2.0 * promote_posterior)
1489        }
1490    };
1491
1492    RolloutExpectedLoss {
1493        hold,
1494        promote,
1495        rollback,
1496    }
1497}
1498
1499fn usize_to_f64(value: usize) -> f64 {
1500    f64::from(u32::try_from(value).unwrap_or(u32::MAX))
1501}
1502
1503fn llc_miss_proxy(total_depth: usize, overflow_depth: usize, overflow_rejected_total: u64) -> f64 {
1504    if total_depth == 0 && overflow_rejected_total == 0 {
1505        return 0.0;
1506    }
1507    let depth_denominator = usize_to_f64(total_depth.max(1));
1508    let overflow_ratio = usize_to_f64(overflow_depth) / depth_denominator;
1509    let rejected_ratio = if overflow_rejected_total == 0 {
1510        0.0
1511    } else {
1512        let rejected = overflow_rejected_total.min(u64::from(u32::MAX));
1513        f64::from(u32::try_from(rejected).unwrap_or(u32::MAX)) / 1_000.0
1514    };
1515    (overflow_ratio + rejected_ratio).clamp(0.0, 1.0)
1516}
1517
1518const fn hostcall_kind_label(kind: &HostcallKind) -> &'static str {
1519    match kind {
1520        HostcallKind::Tool { .. } => "tool",
1521        HostcallKind::Exec { .. } => "exec",
1522        HostcallKind::Http => "http",
1523        HostcallKind::Session { .. } => "session",
1524        HostcallKind::Ui { .. } => "ui",
1525        HostcallKind::Events { .. } => "events",
1526        HostcallKind::Log => "log",
1527    }
1528}
1529
1530fn shannon_entropy_bytes(bytes: &[u8]) -> f64 {
1531    if bytes.is_empty() {
1532        return 0.0;
1533    }
1534    let mut counts = [0_u32; 256];
1535    for &byte in bytes {
1536        counts[usize::from(byte)] = counts[usize::from(byte)].saturating_add(1);
1537    }
1538    let total = f64::from(u32::try_from(bytes.len()).unwrap_or(u32::MAX));
1539    counts
1540        .iter()
1541        .filter(|&&count| count > 0)
1542        .map(|&count| {
1543            let probability = f64::from(count) / total;
1544            -(probability * (probability.ln() / std::f64::consts::LN_2))
1545        })
1546        .sum()
1547}
1548
1549fn hostcall_opcode_entropy(kind: &HostcallKind, payload: &Value) -> f64 {
1550    let kind_label = hostcall_kind_label(kind);
1551    let op = payload
1552        .get("op")
1553        .or_else(|| payload.get("method"))
1554        .or_else(|| payload.get("name"))
1555        .and_then(Value::as_str)
1556        .map(str::trim)
1557        .filter(|value| !value.is_empty());
1558    let capability = payload
1559        .get("capability")
1560        .and_then(Value::as_str)
1561        .map(str::trim)
1562        .filter(|value| !value.is_empty());
1563
1564    // Build the byte histogram directly from segments to avoid per-call
1565    // temporary Vec allocation on the hostcall fast path.
1566    let mut counts = [0_u32; 256];
1567    let mut total = 0_u32;
1568
1569    for &byte in kind_label.as_bytes() {
1570        counts[usize::from(byte)] = counts[usize::from(byte)].saturating_add(1);
1571        total = total.saturating_add(1);
1572    }
1573
1574    if let Some(op) = op {
1575        counts[usize::from(b':')] = counts[usize::from(b':')].saturating_add(1);
1576        total = total.saturating_add(1);
1577        for &byte in op.as_bytes() {
1578            counts[usize::from(byte)] = counts[usize::from(byte)].saturating_add(1);
1579            total = total.saturating_add(1);
1580        }
1581    }
1582
1583    if let Some(capability) = capability {
1584        counts[usize::from(b':')] = counts[usize::from(b':')].saturating_add(1);
1585        total = total.saturating_add(1);
1586        for &byte in capability.as_bytes() {
1587            counts[usize::from(byte)] = counts[usize::from(byte)].saturating_add(1);
1588            total = total.saturating_add(1);
1589        }
1590    }
1591
1592    if total == 0 {
1593        return 0.0;
1594    }
1595
1596    let total_f = f64::from(total);
1597    counts
1598        .iter()
1599        .filter(|&&count| count > 0)
1600        .map(|&count| {
1601            let probability = f64::from(count) / total_f;
1602            -(probability * (probability.ln() / std::f64::consts::LN_2))
1603        })
1604        .sum()
1605}
1606
1607impl<C: SchedulerClock + 'static> ExtensionDispatcher<C> {
1608    fn js_runtime(&self) -> &PiJsRuntime<C> {
1609        self.runtime.as_js_runtime()
1610    }
1611
1612    #[allow(clippy::too_many_arguments)]
1613    pub fn new<R>(
1614        runtime: Rc<R>,
1615        tool_registry: Arc<ToolRegistry>,
1616        http_connector: Arc<HttpConnector>,
1617        session: Arc<dyn ExtensionSession + Send + Sync>,
1618        ui_handler: Arc<dyn ExtensionUiHandler + Send + Sync>,
1619        cwd: PathBuf,
1620    ) -> Self
1621    where
1622        R: ExtensionDispatcherRuntime<C>,
1623    {
1624        Self::new_with_policy(
1625            runtime,
1626            tool_registry,
1627            http_connector,
1628            session,
1629            ui_handler,
1630            cwd,
1631            ExtensionPolicy::from_profile(PolicyProfile::Permissive),
1632        )
1633    }
1634
1635    #[allow(clippy::too_many_arguments)]
1636    pub fn new_with_policy<R>(
1637        runtime: Rc<R>,
1638        tool_registry: Arc<ToolRegistry>,
1639        http_connector: Arc<HttpConnector>,
1640        session: Arc<dyn ExtensionSession + Send + Sync>,
1641        ui_handler: Arc<dyn ExtensionUiHandler + Send + Sync>,
1642        cwd: PathBuf,
1643        policy: ExtensionPolicy,
1644    ) -> Self
1645    where
1646        R: ExtensionDispatcherRuntime<C>,
1647    {
1648        Self::new_with_policy_and_oracle_config(
1649            runtime,
1650            tool_registry,
1651            http_connector,
1652            session,
1653            ui_handler,
1654            cwd,
1655            policy,
1656            DualExecOracleConfig::from_env(),
1657        )
1658    }
1659
1660    #[allow(clippy::too_many_arguments)]
1661    fn new_with_policy_and_oracle_config<R>(
1662        runtime: Rc<R>,
1663        tool_registry: Arc<ToolRegistry>,
1664        http_connector: Arc<HttpConnector>,
1665        session: Arc<dyn ExtensionSession + Send + Sync>,
1666        ui_handler: Arc<dyn ExtensionUiHandler + Send + Sync>,
1667        cwd: PathBuf,
1668        policy: ExtensionPolicy,
1669        dual_exec_config: DualExecOracleConfig,
1670    ) -> Self
1671    where
1672        R: ExtensionDispatcherRuntime<C>,
1673    {
1674        let runtime: Rc<dyn ExtensionDispatcherRuntime<C>> = runtime;
1675        let snapshot_version = policy_snapshot_version(&policy);
1676        let snapshot = PolicySnapshot::compile(&policy);
1677        let io_uring_lane_config = io_uring_lane_policy_from_env();
1678        let io_uring_force_compat = io_uring_force_compat_from_env();
1679        Self {
1680            runtime,
1681            tool_registry,
1682            http_connector,
1683            session,
1684            ui_handler,
1685            cwd,
1686            policy,
1687            snapshot,
1688            snapshot_version,
1689            dual_exec_config,
1690            dual_exec_state: RefCell::new(DualExecOracleState::default()),
1691            io_uring_lane_config,
1692            io_uring_force_compat,
1693            regime_detector: RefCell::new(RegimeShiftDetector::default()),
1694            amac_executor: RefCell::new(
1695                AmacBatchExecutor::new(AmacBatchExecutorConfig::from_env()),
1696            ),
1697        }
1698    }
1699
1700    fn policy_lookup(
1701        &self,
1702        capability: &str,
1703        extension_id: Option<&str>,
1704    ) -> (PolicyCheck, &'static str) {
1705        (
1706            self.snapshot.lookup(capability, extension_id),
1707            policy_lookup_path(capability),
1708        )
1709    }
1710
1711    fn emit_policy_decision_telemetry(
1712        &self,
1713        capability: &str,
1714        extension_id: Option<&str>,
1715        lookup_path: &str,
1716        check: &PolicyCheck,
1717    ) {
1718        tracing::debug!(
1719            target: "pi.extensions.policy_snapshot",
1720            snapshot_version = %self.snapshot_version,
1721            lookup_path,
1722            capability = %capability,
1723            extension_id = %extension_id.unwrap_or("<none>"),
1724            decision = ?check.decision,
1725            decision_provenance = %check.reason,
1726            "Extension policy decision evaluated"
1727        );
1728    }
1729
1730    fn emit_regime_observation_telemetry(
1731        call_id: &str,
1732        observation: RegimeObservation,
1733        queue_depth: usize,
1734        overflow_depth: usize,
1735        overflow_rejected_total: u64,
1736        service_time_us: f64,
1737    ) {
1738        tracing::debug!(
1739            target: "pi.extensions.regime_shift",
1740            call_id,
1741            adaptation_mode = observation.mode.as_str(),
1742            composite_score = observation.score,
1743            baseline_mean = observation.mean,
1744            baseline_stddev = observation.stddev,
1745            upper_cusum = observation.upper_cusum,
1746            lower_cusum = observation.lower_cusum,
1747            change_posterior = observation.change_posterior,
1748            queue_depth,
1749            overflow_depth,
1750            overflow_rejected_total,
1751            service_time_us,
1752            fallback_triggered = observation.fallback_triggered,
1753            rollout_action = observation.rollout_action.as_str(),
1754            rollout_stratum = observation.rollout_stratum.as_str(),
1755            rollout_promote_posterior = observation.rollout_promote_posterior,
1756            rollout_rollback_posterior = observation.rollout_rollback_posterior,
1757            rollout_promote_e_process = observation.rollout_promote_e_process,
1758            rollout_rollback_e_process = observation.rollout_rollback_e_process,
1759            rollout_evidence_threshold = observation.rollout_evidence_threshold,
1760            rollout_expected_loss_hold = observation.rollout_expected_loss.hold,
1761            rollout_expected_loss_promote = observation.rollout_expected_loss.promote,
1762            rollout_expected_loss_rollback = observation.rollout_expected_loss.rollback,
1763            rollout_samples_total = observation.rollout_total_samples,
1764            rollout_samples_high = observation.rollout_high_samples,
1765            rollout_samples_low = observation.rollout_low_samples,
1766            rollout_coverage_ready = observation.rollout_coverage_ready,
1767            rollout_blocked_underpowered = observation.rollout_blocked_underpowered,
1768            rollout_blocked_cherry_picked = observation.rollout_blocked_cherry_picked,
1769            "Hostcall regime observation recorded"
1770        );
1771        if let Some(transition) = observation.transition {
1772            tracing::info!(
1773                target: "pi.extensions.regime_shift",
1774                call_id,
1775                transition = transition.as_str(),
1776                adaptation_mode = observation.mode.as_str(),
1777                score = observation.score,
1778                change_posterior = observation.change_posterior,
1779                queue_depth,
1780                service_time_us,
1781                fallback_triggered = observation.fallback_triggered,
1782                rollout_action = observation.rollout_action.as_str(),
1783                rollout_promote_posterior = observation.rollout_promote_posterior,
1784                rollout_rollback_posterior = observation.rollout_rollback_posterior,
1785                rollout_promote_e_process = observation.rollout_promote_e_process,
1786                rollout_rollback_e_process = observation.rollout_rollback_e_process,
1787                rollout_expected_loss_hold = observation.rollout_expected_loss.hold,
1788                rollout_expected_loss_promote = observation.rollout_expected_loss.promote,
1789                rollout_expected_loss_rollback = observation.rollout_expected_loss.rollback,
1790                rollout_samples_total = observation.rollout_total_samples,
1791                rollout_samples_high = observation.rollout_high_samples,
1792                rollout_samples_low = observation.rollout_low_samples,
1793                rollout_coverage_ready = observation.rollout_coverage_ready,
1794                rollout_blocked_underpowered = observation.rollout_blocked_underpowered,
1795                rollout_blocked_cherry_picked = observation.rollout_blocked_cherry_picked,
1796                "Hostcall regime transition accepted"
1797            );
1798        }
1799    }
1800
1801    #[allow(clippy::too_many_arguments)]
1802    fn emit_io_uring_lane_telemetry(
1803        &self,
1804        request: &HostcallRequest,
1805        capability: &str,
1806        capability_class: HostcallCapabilityClass,
1807        io_hint: HostcallIoHint,
1808        queue_depth: usize,
1809        selected_lane: HostcallDispatchLane,
1810        fallback_reason: Option<&'static str>,
1811    ) {
1812        let queue_budget = self.io_uring_lane_config.max_queue_depth.max(1);
1813        let depth_u64 = u64::try_from(queue_depth).unwrap_or(u64::MAX);
1814        let budget_u64 = u64::try_from(queue_budget).unwrap_or(u64::MAX).max(1);
1815        let occupancy_permille = depth_u64.saturating_mul(1_000).saturating_div(budget_u64);
1816        tracing::debug!(
1817            target: "pi.extensions.io_uring_lane",
1818            call_id = request.call_id,
1819            extension_id = %request.extension_id.as_deref().unwrap_or("<none>"),
1820            method = request.method(),
1821            capability = %capability,
1822            capability_class = hostcall_capability_label(capability_class),
1823            io_hint = hostcall_io_hint_label(io_hint),
1824            selected_lane = selected_lane.as_str(),
1825            fallback_reason = %fallback_reason.unwrap_or("none"),
1826            queue_depth,
1827            queue_budget,
1828            queue_occupancy_permille = occupancy_permille,
1829            io_uring_enabled = self.io_uring_lane_config.enabled,
1830            io_uring_ring_available = self.io_uring_lane_config.ring_available,
1831            io_uring_force_compat = self.io_uring_force_compat,
1832            "Hostcall io_uring lane decision evaluated"
1833        );
1834    }
1835
1836    fn emit_io_uring_bridge_telemetry(
1837        &self,
1838        request: &HostcallRequest,
1839        state: IoUringBridgeState,
1840        fallback_reason: Option<&'static str>,
1841    ) {
1842        tracing::debug!(
1843            target: "pi.extensions.io_uring_bridge",
1844            call_id = request.call_id,
1845            extension_id = %request.extension_id.as_deref().unwrap_or("<none>"),
1846            method = request.method(),
1847            state = state.as_str(),
1848            fallback_reason = %fallback_reason.unwrap_or("none"),
1849            io_uring_enabled = self.io_uring_lane_config.enabled,
1850            io_uring_ring_available = self.io_uring_lane_config.ring_available,
1851            io_uring_force_compat = self.io_uring_force_compat,
1852            "Hostcall io_uring bridge dispatch completed"
1853        );
1854    }
1855
1856    const fn advanced_dispatch_enabled(&self) -> bool {
1857        self.dual_exec_config.sample_ppm > 0 || self.io_uring_lane_active()
1858    }
1859
1860    #[inline]
1861    const fn io_uring_lane_active(&self) -> bool {
1862        self.io_uring_lane_config.enabled || self.io_uring_force_compat
1863    }
1864
1865    /// Drain pending hostcall requests from the JS runtime.
1866    #[must_use]
1867    pub fn drain_hostcall_requests(&self) -> VecDeque<HostcallRequest> {
1868        self.js_runtime().drain_hostcall_requests()
1869    }
1870
1871    #[allow(clippy::future_not_send)]
1872    async fn dispatch_hostcall_fast(&self, request: &HostcallRequest) -> HostcallOutcome {
1873        let cap = request.required_capability();
1874        let (check, lookup_path) = self.policy_lookup(cap, request.extension_id.as_deref());
1875        self.emit_policy_decision_telemetry(
1876            cap,
1877            request.extension_id.as_deref(),
1878            lookup_path,
1879            &check,
1880        );
1881        if check.decision != PolicyDecision::Allow {
1882            return HostcallOutcome::Error {
1883                code: "denied".to_string(),
1884                message: format!("Capability '{}' denied by policy ({})", cap, check.reason),
1885            };
1886        }
1887
1888        match &request.kind {
1889            HostcallKind::Tool { name } => {
1890                self.dispatch_tool(&request.call_id, name, request.payload.clone())
1891                    .await
1892            }
1893            HostcallKind::Exec { cmd } => {
1894                self.dispatch_exec_ref(&request.call_id, cmd, &request.payload)
1895                    .await
1896            }
1897            HostcallKind::Http => {
1898                self.dispatch_http(&request.call_id, request.payload.clone())
1899                    .await
1900            }
1901            HostcallKind::Session { op } => {
1902                self.dispatch_session_ref(&request.call_id, op, &request.payload)
1903                    .await
1904            }
1905            HostcallKind::Ui { op } => {
1906                self.dispatch_ui(
1907                    &request.call_id,
1908                    op,
1909                    request.payload.clone(),
1910                    request.extension_id.as_deref(),
1911                )
1912                .await
1913            }
1914            HostcallKind::Events { op } => {
1915                self.dispatch_events_ref(
1916                    &request.call_id,
1917                    request.extension_id.as_deref(),
1918                    op,
1919                    &request.payload,
1920                )
1921                .await
1922            }
1923            HostcallKind::Log => {
1924                tracing::info!(
1925                    target: "pi.extension.log",
1926                    payload = ?request.payload,
1927                    "Extension log"
1928                );
1929                HostcallOutcome::Success(serde_json::json!({ "logged": true }))
1930            }
1931        }
1932    }
1933
1934    #[allow(clippy::future_not_send)]
1935    async fn dispatch_hostcall_io_uring(&self, request: &HostcallRequest) -> IoUringBridgeDispatch {
1936        if !self.js_runtime().is_hostcall_pending(&request.call_id) {
1937            return IoUringBridgeDispatch {
1938                outcome: HostcallOutcome::Error {
1939                    code: "cancelled".to_string(),
1940                    message: "Hostcall cancelled before io_uring dispatch".to_string(),
1941                },
1942                state: IoUringBridgeState::CancelledBeforeDispatch,
1943                fallback_reason: Some("cancelled_before_io_uring_dispatch"),
1944            };
1945        }
1946
1947        // io_uring submission/completion wiring is introduced incrementally.
1948        // Keep bridge semantics explicit while delegating execution to the
1949        // existing fast hostcall path until the ring executor lands.
1950        let delegated_outcome = self.dispatch_hostcall_fast(request).await;
1951        if !self.js_runtime().is_hostcall_pending(&request.call_id) {
1952            return IoUringBridgeDispatch {
1953                outcome: HostcallOutcome::Error {
1954                    code: "cancelled".to_string(),
1955                    message: "Hostcall cancelled before io_uring completion".to_string(),
1956                },
1957                state: IoUringBridgeState::CancelledAfterDispatch,
1958                fallback_reason: Some("cancelled_before_io_uring_completion"),
1959            };
1960        }
1961
1962        IoUringBridgeDispatch {
1963            outcome: delegated_outcome,
1964            state: IoUringBridgeState::DelegatedFastPath,
1965            fallback_reason: Some("io_uring_bridge_delegated_fast_path"),
1966        }
1967    }
1968
1969    #[allow(clippy::future_not_send)]
1970    async fn dispatch_hostcall_compat_shadow(&self, request: &HostcallRequest) -> HostcallOutcome {
1971        let payload = HostCallPayload {
1972            call_id: request.call_id.clone(),
1973            capability: request.required_capability().to_string(),
1974            method: request.method().to_string(),
1975            params: protocol_params_from_request(request),
1976            timeout_ms: None,
1977            cancel_token: None,
1978            context: None,
1979        };
1980        self.dispatch_protocol_host_call(&payload).await
1981    }
1982
1983    #[allow(clippy::future_not_send)]
1984    async fn run_shadow_dual_exec(
1985        &self,
1986        request: &HostcallRequest,
1987        fast_outcome: &HostcallOutcome,
1988    ) {
1989        let config = self.dual_exec_config;
1990        if config.sample_ppm == 0 {
1991            return;
1992        }
1993
1994        {
1995            let mut state = self.dual_exec_state.borrow_mut();
1996            state.begin_request();
1997            if state.overhead_backoff_remaining > 0 {
1998                return;
1999            }
2000            if !is_shadow_safe_request(request) {
2001                state.skipped_unsupported_total = state.skipped_unsupported_total.saturating_add(1);
2002                return;
2003            }
2004        }
2005
2006        if !should_sample_shadow_dual_exec(request, config.sample_ppm) {
2007            return;
2008        }
2009
2010        let shadow_started_at = Instant::now();
2011        let compat_outcome = self.dispatch_hostcall_compat_shadow(request).await;
2012        let shadow_elapsed_us = shadow_started_at.elapsed().as_secs_f64() * 1_000_000.0;
2013
2014        let diff = diff_hostcall_outcomes(fast_outcome, &compat_outcome);
2015        let rollback_reason = {
2016            let mut state = self.dual_exec_state.borrow_mut();
2017            #[allow(clippy::cast_precision_loss)]
2018            if shadow_elapsed_us > config.overhead_budget_us as f64 {
2019                state.record_overhead_budget_exceeded(config);
2020                tracing::warn!(
2021                    target: "pi.extensions.dual_exec",
2022                    call_id = request.call_id,
2023                    extension_id = %request.extension_id.as_deref().unwrap_or("<none>"),
2024                    method = request.method(),
2025                    shadow_elapsed_us,
2026                    overhead_budget_us = config.overhead_budget_us,
2027                    backoff_requests = state.overhead_backoff_remaining,
2028                    "Shadow dual execution exceeded overhead budget; backoff enabled"
2029                );
2030            }
2031
2032            let divergent = diff.is_some();
2033            state.record_sample(divergent, config, request.extension_id.as_deref())
2034        };
2035
2036        if let Some(diff) = diff {
2037            let forensic_bundle = dual_exec_forensic_bundle(
2038                request,
2039                &diff,
2040                rollback_reason.as_deref(),
2041                shadow_elapsed_us,
2042            );
2043            tracing::warn!(
2044                target: "pi.extensions.dual_exec",
2045                call_id = request.call_id,
2046                extension_id = %request.extension_id.as_deref().unwrap_or("<none>"),
2047                method = request.method(),
2048                rollback_triggered = rollback_reason.is_some(),
2049                rollback_reason = %rollback_reason.as_deref().unwrap_or("none"),
2050                forensic_bundle = %forensic_bundle,
2051                "Shadow dual execution divergence detected"
2052            );
2053        } else {
2054            tracing::trace!(
2055                target: "pi.extensions.dual_exec",
2056                call_id = request.call_id,
2057                extension_id = %request.extension_id.as_deref().unwrap_or("<none>"),
2058                method = request.method(),
2059                shadow_elapsed_us,
2060                "Shadow dual execution matched"
2061            );
2062        }
2063    }
2064
2065    /// Dispatch a hostcall and enqueue its completion into the JS scheduler.
2066    #[allow(clippy::future_not_send, clippy::too_many_lines)]
2067    pub async fn dispatch_and_complete(&self, request: HostcallRequest) {
2068        let cap = request.required_capability();
2069        let (check, lookup_path) = self.policy_lookup(cap, request.extension_id.as_deref());
2070        self.emit_policy_decision_telemetry(
2071            cap,
2072            request.extension_id.as_deref(),
2073            lookup_path,
2074            &check,
2075        );
2076        if check.decision != PolicyDecision::Allow {
2077            let outcome = HostcallOutcome::Error {
2078                code: "denied".to_string(),
2079                message: format!("Capability '{}' denied by policy ({})", cap, check.reason),
2080            };
2081            self.js_runtime()
2082                .complete_hostcall(request.call_id, outcome);
2083            return;
2084        }
2085
2086        if !self.advanced_dispatch_enabled() {
2087            let outcome = self.dispatch_hostcall_fast(&request).await;
2088            self.js_runtime()
2089                .complete_hostcall(request.call_id, outcome);
2090            return;
2091        }
2092
2093        let dispatch_started_at = Instant::now();
2094        let mut queue_depth = 1_usize;
2095        let mut overflow_depth = 0_usize;
2096        let mut overflow_rejected_total = 0_u64;
2097
2098        let (outcome, lane_for_shadow) = if self.io_uring_lane_active() {
2099            let queue_snapshot = self.js_runtime().hostcall_queue_telemetry();
2100            queue_depth = queue_snapshot.total_depth;
2101            overflow_depth = queue_snapshot.overflow_depth;
2102            overflow_rejected_total = queue_snapshot.overflow_rejected_total;
2103
2104            let io_hint = hostcall_io_hint(&request.kind);
2105            let capability_class = HostcallCapabilityClass::from_capability(cap);
2106            let lane_decision = decide_io_uring_lane(
2107                self.io_uring_lane_config,
2108                IoUringLaneDecisionInput {
2109                    capability: capability_class,
2110                    io_hint,
2111                    queue_depth,
2112                    force_compat_lane: self.io_uring_force_compat,
2113                },
2114            );
2115            self.emit_io_uring_lane_telemetry(
2116                &request,
2117                cap,
2118                capability_class,
2119                io_hint,
2120                queue_depth,
2121                lane_decision.lane,
2122                lane_decision.fallback_code(),
2123            );
2124
2125            let outcome = match lane_decision.lane {
2126                HostcallDispatchLane::Fast => self.dispatch_hostcall_fast(&request).await,
2127                HostcallDispatchLane::IoUring => {
2128                    let bridge_dispatch = self.dispatch_hostcall_io_uring(&request).await;
2129                    self.emit_io_uring_bridge_telemetry(
2130                        &request,
2131                        bridge_dispatch.state,
2132                        bridge_dispatch.fallback_reason,
2133                    );
2134                    bridge_dispatch.outcome
2135                }
2136                HostcallDispatchLane::Compat => {
2137                    self.dispatch_hostcall_compat_shadow(&request).await
2138                }
2139            };
2140            (outcome, lane_decision.lane)
2141        } else {
2142            (
2143                self.dispatch_hostcall_fast(&request).await,
2144                HostcallDispatchLane::Fast,
2145            )
2146        };
2147
2148        if lane_for_shadow != HostcallDispatchLane::Compat {
2149            self.run_shadow_dual_exec(&request, &outcome).await;
2150        }
2151
2152        let service_time_us = dispatch_started_at.elapsed().as_secs_f64() * 1_000_000.0;
2153        let opcode_entropy = hostcall_opcode_entropy(&request.kind, &request.payload);
2154        let llc_miss_rate = llc_miss_proxy(queue_depth, overflow_depth, overflow_rejected_total);
2155        let regime_signal = RegimeSignal {
2156            queue_depth: usize_to_f64(queue_depth),
2157            service_time_us,
2158            opcode_entropy,
2159            llc_miss_rate,
2160        };
2161        let observation = {
2162            let mut detector = self.regime_detector.borrow_mut();
2163            detector.observe(regime_signal)
2164        };
2165        Self::emit_regime_observation_telemetry(
2166            &request.call_id,
2167            observation,
2168            queue_depth,
2169            overflow_depth,
2170            overflow_rejected_total,
2171            service_time_us,
2172        );
2173
2174        self.js_runtime()
2175            .complete_hostcall(request.call_id, outcome);
2176    }
2177
2178    /// Dispatch a batch of hostcall requests using AMAC-aware grouping.
2179    ///
2180    /// Groups requests by kind, decides per-group whether to interleave or
2181    /// use sequential dispatch, then dispatches accordingly. Falls back to
2182    /// sequential one-by-one dispatch when AMAC is disabled or the batch is
2183    /// too small.
2184    #[allow(clippy::future_not_send)]
2185    pub async fn dispatch_batch_amac(&self, mut requests: VecDeque<HostcallRequest>) {
2186        if requests.is_empty() {
2187            return;
2188        }
2189
2190        let (rollback_active, rollback_remaining, rollback_reason) = {
2191            let state = self.dual_exec_state.borrow();
2192            (
2193                state.rollback_active(),
2194                state.rollback_remaining,
2195                state
2196                    .rollback_reason
2197                    .clone()
2198                    .unwrap_or_else(|| "dual_exec_rollback_active".to_string()),
2199            )
2200        };
2201
2202        // Check if AMAC is enabled before consuming requests.
2203        let amac_enabled = self.amac_executor.borrow().enabled();
2204        let adaptation_mode = self.regime_detector.borrow().current_mode();
2205        let rollout_forces_sequential = adaptation_mode == RegimeAdaptationMode::SequentialFastPath;
2206        if !amac_enabled || rollback_active || rollout_forces_sequential {
2207            if rollback_active {
2208                tracing::warn!(
2209                    target: "pi.extensions.dual_exec",
2210                    rollback_remaining,
2211                    rollback_reason = %rollback_reason,
2212                    "Dual-exec rollback forcing sequential dispatcher mode"
2213                );
2214            } else if rollout_forces_sequential && amac_enabled {
2215                tracing::debug!(
2216                    target: "pi.extensions.regime_shift",
2217                    adaptation_mode = adaptation_mode.as_str(),
2218                    "Rollout gate forcing sequential dispatch mode"
2219                );
2220            }
2221            // Dispatch sequentially without AMAC overhead.
2222            while let Some(req) = requests.pop_front() {
2223                self.dispatch_and_complete(req).await;
2224            }
2225            return;
2226        }
2227
2228        let request_vec: Vec<HostcallRequest> = requests.into();
2229        let plan = self.amac_executor.borrow_mut().plan_batch(request_vec);
2230
2231        for (group, decision) in plan.groups.into_iter().zip(plan.decisions.iter()) {
2232            let group_key = group.key.clone();
2233            let start = Instant::now();
2234            // Dispatch each request in the group sequentially.
2235            // AMAC decision metadata is recorded for telemetry but the
2236            // actual dispatch remains sequential within a single-threaded
2237            // async executor — true concurrency is achieved at the reactor
2238            // mesh level (bd-3ar8v.4.20).
2239            for request in group.requests {
2240                let req_start = Instant::now();
2241                self.dispatch_and_complete(request).await;
2242                let elapsed_ns = u64::try_from(req_start.elapsed().as_nanos()).unwrap_or(u64::MAX);
2243                self.amac_executor.borrow_mut().observe_call(elapsed_ns);
2244            }
2245
2246            let group_elapsed_ns = u64::try_from(start.elapsed().as_nanos()).unwrap_or(u64::MAX);
2247            tracing::trace!(
2248                target: "pi.extensions.amac",
2249                group_key = ?group_key,
2250                decision = ?decision,
2251                group_elapsed_ns,
2252                "AMAC group dispatched"
2253            );
2254        }
2255    }
2256
2257    /// Protocol adapter: convert `ExtensionMessage(type=host_call)` into
2258    /// `ExtensionMessage(type=host_result)` using the same dispatch paths used
2259    /// by runtime hostcalls.
2260    #[allow(clippy::future_not_send)]
2261    pub async fn dispatch_protocol_message(
2262        &self,
2263        message: ExtensionMessage,
2264    ) -> Result<ExtensionMessage> {
2265        let ExtensionMessage { id, version, body } = message;
2266        if id.trim().is_empty() {
2267            return Err(crate::error::Error::validation(
2268                "Extension message id is empty",
2269            ));
2270        }
2271        if version != PROTOCOL_VERSION {
2272            return Err(crate::error::Error::validation(format!(
2273                "Unsupported extension protocol version: {version}"
2274            )));
2275        }
2276        let ExtensionBody::HostCall(payload) = body else {
2277            return Err(crate::error::Error::validation(
2278                "dispatch_protocol_message expects host_call message",
2279            ));
2280        };
2281
2282        let outcome = match validate_host_call(&payload) {
2283            Ok(()) => self.dispatch_protocol_host_call(&payload).await,
2284            Err(crate::error::Error::Validation(message)) => {
2285                if payload.call_id.trim().is_empty() {
2286                    return Err(crate::error::Error::Validation(message));
2287                }
2288                HostcallOutcome::Error {
2289                    code: "invalid_request".to_string(),
2290                    message,
2291                }
2292            }
2293            Err(err) => return Err(err),
2294        };
2295        let response = ExtensionMessage {
2296            id,
2297            version,
2298            body: ExtensionBody::HostResult(hostcall_outcome_to_protocol_result_with_trace(
2299                &payload, outcome,
2300            )),
2301        };
2302        response.validate()?;
2303        Ok(response)
2304    }
2305
2306    #[allow(clippy::future_not_send, clippy::too_many_lines)]
2307    async fn dispatch_protocol_host_call(&self, payload: &HostCallPayload) -> HostcallOutcome {
2308        if let Some(cap) = required_capability_for_host_call_static(payload) {
2309            let (check, lookup_path) = self.policy_lookup(cap, None);
2310            self.emit_policy_decision_telemetry(cap, None, lookup_path, &check);
2311            if check.decision != PolicyDecision::Allow {
2312                return HostcallOutcome::Error {
2313                    code: "denied".to_string(),
2314                    message: format!("Capability '{}' denied by policy ({})", cap, check.reason),
2315                };
2316            }
2317        }
2318
2319        let method = payload.method.trim();
2320
2321        match parse_protocol_hostcall_method(method) {
2322            Some(ProtocolHostcallMethod::Tool) => {
2323                let Some(name) = payload
2324                    .params
2325                    .get("name")
2326                    .and_then(Value::as_str)
2327                    .map(str::trim)
2328                    .filter(|name| !name.is_empty())
2329                else {
2330                    return HostcallOutcome::Error {
2331                        code: "invalid_request".to_string(),
2332                        message: "host_call tool requires params.name".to_string(),
2333                    };
2334                };
2335                let input = payload
2336                    .params
2337                    .get("input")
2338                    .cloned()
2339                    .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
2340                self.dispatch_tool(&payload.call_id, name, input).await
2341            }
2342            Some(ProtocolHostcallMethod::Exec) => {
2343                let Some(cmd) = payload
2344                    .params
2345                    .get("cmd")
2346                    .or_else(|| payload.params.get("command"))
2347                    .and_then(Value::as_str)
2348                    .map(str::trim)
2349                    .filter(|cmd| !cmd.is_empty())
2350                else {
2351                    return HostcallOutcome::Error {
2352                        code: "invalid_request".to_string(),
2353                        message: "host_call exec requires params.cmd or params.command".to_string(),
2354                    };
2355                };
2356
2357                // SEC-4.3: Exec mediation — classify and gate dangerous commands.
2358                let args: Vec<String> = payload
2359                    .params
2360                    .get("args")
2361                    .and_then(Value::as_array)
2362                    .map(|arr| {
2363                        arr.iter()
2364                            .filter_map(|v| v.as_str().map(ToString::to_string))
2365                            .collect()
2366                    })
2367                    .unwrap_or_default();
2368                let mediation = evaluate_exec_mediation(&self.policy.exec_mediation, cmd, &args);
2369                match &mediation {
2370                    ExecMediationResult::Deny { class, reason } => {
2371                        tracing::warn!(
2372                            event = "exec.mediation.deny",
2373                            command_class = ?class.map(DangerousCommandClass::label),
2374                            reason = %reason,
2375                            "Exec command denied by mediation policy"
2376                        );
2377                        return HostcallOutcome::Error {
2378                            code: "denied".to_string(),
2379                            message: format!("Exec denied by mediation policy: {reason}"),
2380                        };
2381                    }
2382                    ExecMediationResult::AllowWithAudit { class, reason } => {
2383                        tracing::info!(
2384                            event = "exec.mediation.audit",
2385                            command_class = class.label(),
2386                            reason = %reason,
2387                            "Exec command allowed with audit"
2388                        );
2389                    }
2390                    ExecMediationResult::Allow => {}
2391                }
2392
2393                self.dispatch_exec_ref(&payload.call_id, cmd, &payload.params)
2394                    .await
2395            }
2396            Some(ProtocolHostcallMethod::Http) => {
2397                self.dispatch_http(&payload.call_id, payload.params.clone())
2398                    .await
2399            }
2400            Some(ProtocolHostcallMethod::Session) => {
2401                let Some(op) = protocol_hostcall_op(&payload.params) else {
2402                    return HostcallOutcome::Error {
2403                        code: "invalid_request".to_string(),
2404                        message: "host_call session requires params.op".to_string(),
2405                    };
2406                };
2407                self.dispatch_session_ref(&payload.call_id, op, &payload.params)
2408                    .await
2409            }
2410            Some(ProtocolHostcallMethod::Ui) => {
2411                let Some(op) = protocol_hostcall_op(&payload.params) else {
2412                    return HostcallOutcome::Error {
2413                        code: "invalid_request".to_string(),
2414                        message: "host_call ui requires params.op".to_string(),
2415                    };
2416                };
2417                self.dispatch_ui(&payload.call_id, op, payload.params.clone(), None)
2418                    .await
2419            }
2420            Some(ProtocolHostcallMethod::Events) => {
2421                let Some(op) = protocol_hostcall_op(&payload.params) else {
2422                    return HostcallOutcome::Error {
2423                        code: "invalid_request".to_string(),
2424                        message: "host_call events requires params.op".to_string(),
2425                    };
2426                };
2427                self.dispatch_events_ref(&payload.call_id, None, op, &payload.params)
2428                    .await
2429            }
2430            Some(ProtocolHostcallMethod::Log) => {
2431                tracing::info!(
2432                    target: "pi.extension.log",
2433                    payload = ?payload.params,
2434                    "Extension log"
2435                );
2436                HostcallOutcome::Success(serde_json::json!({ "logged": true }))
2437            }
2438            None => HostcallOutcome::Error {
2439                code: "invalid_request".to_string(),
2440                message: format!("Unsupported host_call method: {method}"),
2441            },
2442        }
2443    }
2444
2445    #[allow(clippy::future_not_send)]
2446    async fn dispatch_tool(
2447        &self,
2448        call_id: &str,
2449        name: &str,
2450        payload: serde_json::Value,
2451    ) -> HostcallOutcome {
2452        let Some(tool) = self.tool_registry.get(name) else {
2453            return HostcallOutcome::Error {
2454                code: "invalid_request".to_string(),
2455                message: format!("Unknown tool: {name}"),
2456            };
2457        };
2458
2459        match tool.execute(call_id, payload, None).await {
2460            Ok(output) => match serde_json::to_value(output) {
2461                Ok(value) => HostcallOutcome::Success(value),
2462                Err(err) => HostcallOutcome::Error {
2463                    code: "internal".to_string(),
2464                    message: format!("Serialize tool output: {err}"),
2465                },
2466            },
2467            Err(err) => HostcallOutcome::Error {
2468                code: "io".to_string(),
2469                message: err.to_string(),
2470            },
2471        }
2472    }
2473
2474    #[allow(clippy::future_not_send)]
2475    async fn dispatch_exec(
2476        &self,
2477        call_id: &str,
2478        cmd: &str,
2479        payload: serde_json::Value,
2480    ) -> HostcallOutcome {
2481        self.dispatch_exec_ref(call_id, cmd, &payload).await
2482    }
2483
2484    #[allow(clippy::future_not_send, clippy::too_many_lines)]
2485    async fn dispatch_exec_ref(
2486        &self,
2487        call_id: &str,
2488        cmd: &str,
2489        payload: &serde_json::Value,
2490    ) -> HostcallOutcome {
2491        use std::process::{Command, Stdio};
2492        use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
2493        use std::sync::mpsc::{self, SyncSender};
2494
2495        enum ExecStreamFrame {
2496            Stdout(String),
2497            Stderr(String),
2498            Final { code: i32, killed: bool },
2499            Error(String),
2500        }
2501
2502        fn pump_stream<R: std::io::Read>(
2503            mut reader: R,
2504            tx: &SyncSender<ExecStreamFrame>,
2505            stdout: bool,
2506        ) -> std::result::Result<(), String> {
2507            let mut buf = [0u8; 4096];
2508            let mut partial = Vec::new();
2509
2510            loop {
2511                let read = reader.read(&mut buf).map_err(|err| err.to_string())?;
2512                if read == 0 {
2513                    // EOF. Flush partial if any (lossy).
2514                    if !partial.is_empty() {
2515                        let text = String::from_utf8_lossy(&partial).to_string();
2516                        let frame = if stdout {
2517                            ExecStreamFrame::Stdout(text)
2518                        } else {
2519                            ExecStreamFrame::Stderr(text)
2520                        };
2521                        let _ = tx.send(frame);
2522                    }
2523                    break;
2524                }
2525
2526                let chunk = &buf[..read];
2527
2528                // If we have partial data, we must append the new chunk and process the combined buffer.
2529                // If partial is empty, we can process the chunk directly (fast path).
2530                if partial.is_empty() {
2531                    let mut processed = 0;
2532                    loop {
2533                        match std::str::from_utf8(&chunk[processed..]) {
2534                            Ok(s) => {
2535                                if !s.is_empty() {
2536                                    let frame = if stdout {
2537                                        ExecStreamFrame::Stdout(s.to_string())
2538                                    } else {
2539                                        ExecStreamFrame::Stderr(s.to_string())
2540                                    };
2541                                    if tx.send(frame).is_err() {
2542                                        return Ok(());
2543                                    }
2544                                }
2545                                break;
2546                            }
2547                            Err(e) => {
2548                                let valid_len = e.valid_up_to();
2549                                if valid_len > 0 {
2550                                    let s = std::str::from_utf8(
2551                                        &chunk[processed..processed + valid_len],
2552                                    )
2553                                    .expect("valid utf8 prefix");
2554                                    let frame = if stdout {
2555                                        ExecStreamFrame::Stdout(s.to_string())
2556                                    } else {
2557                                        ExecStreamFrame::Stderr(s.to_string())
2558                                    };
2559                                    if tx.send(frame).is_err() {
2560                                        return Ok(());
2561                                    }
2562                                    processed += valid_len;
2563                                }
2564
2565                                if let Some(len) = e.error_len() {
2566                                    // Invalid sequence: emit replacement and skip
2567                                    let frame = if stdout {
2568                                        ExecStreamFrame::Stdout("\u{FFFD}".to_string())
2569                                    } else {
2570                                        ExecStreamFrame::Stderr("\u{FFFD}".to_string())
2571                                    };
2572                                    if tx.send(frame).is_err() {
2573                                        return Ok(());
2574                                    }
2575                                    processed += len;
2576                                } else {
2577                                    // Incomplete at end: buffer the remainder
2578                                    partial.extend_from_slice(&chunk[processed..]);
2579                                    break;
2580                                }
2581                            }
2582                        }
2583                    }
2584                } else {
2585                    partial.extend_from_slice(chunk);
2586                    let mut processed = 0;
2587                    loop {
2588                        match std::str::from_utf8(&partial[processed..]) {
2589                            Ok(s) => {
2590                                if !s.is_empty() {
2591                                    let frame = if stdout {
2592                                        ExecStreamFrame::Stdout(s.to_string())
2593                                    } else {
2594                                        ExecStreamFrame::Stderr(s.to_string())
2595                                    };
2596                                    if tx.send(frame).is_err() {
2597                                        return Ok(());
2598                                    }
2599                                }
2600                                partial.clear();
2601                                break;
2602                            }
2603                            Err(e) => {
2604                                let valid_len = e.valid_up_to();
2605                                if valid_len > 0 {
2606                                    let s = std::str::from_utf8(
2607                                        &partial[processed..processed + valid_len],
2608                                    )
2609                                    .expect("valid utf8 prefix");
2610                                    let frame = if stdout {
2611                                        ExecStreamFrame::Stdout(s.to_string())
2612                                    } else {
2613                                        ExecStreamFrame::Stderr(s.to_string())
2614                                    };
2615                                    if tx.send(frame).is_err() {
2616                                        return Ok(());
2617                                    }
2618                                    processed += valid_len;
2619                                }
2620
2621                                if let Some(len) = e.error_len() {
2622                                    // Invalid sequence
2623                                    let frame = if stdout {
2624                                        ExecStreamFrame::Stdout("\u{FFFD}".to_string())
2625                                    } else {
2626                                        ExecStreamFrame::Stderr("\u{FFFD}".to_string())
2627                                    };
2628                                    if tx.send(frame).is_err() {
2629                                        return Ok(());
2630                                    }
2631                                    processed += len;
2632                                } else {
2633                                    // Incomplete at end
2634                                    // Move remaining bytes to start of partial
2635                                    let remaining = partial.len() - processed;
2636                                    partial.copy_within(processed.., 0);
2637                                    partial.truncate(remaining);
2638                                    break;
2639                                }
2640                            }
2641                        }
2642                    }
2643                }
2644            }
2645            Ok(())
2646        }
2647
2648        #[allow(clippy::unnecessary_lazy_evaluations)] // lazy eval needed on unix for signal()
2649        fn exit_status_code(status: std::process::ExitStatus) -> i32 {
2650            status.code().unwrap_or_else(|| {
2651                #[cfg(unix)]
2652                {
2653                    use std::os::unix::process::ExitStatusExt as _;
2654                    status.signal().map_or(-1, |signal| -signal)
2655                }
2656                #[cfg(not(unix))]
2657                {
2658                    -1
2659                }
2660            })
2661        }
2662
2663        let args = match payload.get("args") {
2664            None | Some(serde_json::Value::Null) => Vec::new(),
2665            Some(serde_json::Value::Array(items)) => items
2666                .iter()
2667                .map(|value| {
2668                    value
2669                        .as_str()
2670                        .map_or_else(|| value.to_string(), ToString::to_string)
2671                })
2672                .collect::<Vec<_>>(),
2673            Some(_) => {
2674                return HostcallOutcome::Error {
2675                    code: "invalid_request".to_string(),
2676                    message: "exec args must be an array".to_string(),
2677                };
2678            }
2679        };
2680
2681        let options = payload
2682            .get("options")
2683            .and_then(serde_json::Value::as_object);
2684        let cwd = options
2685            .and_then(|opts| opts.get("cwd"))
2686            .and_then(serde_json::Value::as_str)
2687            .map_or_else(|| self.cwd.clone(), PathBuf::from);
2688        let timeout_ms = options
2689            .and_then(|opts| {
2690                opts.get("timeout")
2691                    .and_then(serde_json::Value::as_u64)
2692                    .or_else(|| opts.get("timeoutMs").and_then(serde_json::Value::as_u64))
2693                    .or_else(|| opts.get("timeout_ms").and_then(serde_json::Value::as_u64))
2694            })
2695            .filter(|ms| *ms > 0);
2696        let stream = options
2697            .and_then(|opts| opts.get("stream"))
2698            .and_then(serde_json::Value::as_bool)
2699            .unwrap_or(false);
2700
2701        if stream {
2702            struct CancelGuard(Arc<AtomicBool>);
2703            impl Drop for CancelGuard {
2704                fn drop(&mut self) {
2705                    self.0.store(true, AtomicOrdering::SeqCst);
2706                }
2707            }
2708
2709            let cmd = cmd.to_string();
2710            let args = args.clone();
2711            let (tx, rx) = mpsc::sync_channel::<ExecStreamFrame>(1024);
2712            let cancel = Arc::new(AtomicBool::new(false));
2713            let cancel_worker = Arc::clone(&cancel);
2714            let call_id_for_error = call_id.to_string();
2715
2716            thread::spawn(move || {
2717                let result = (|| -> std::result::Result<(), String> {
2718                    let mut command = Command::new(&cmd);
2719                    command
2720                        .args(&args)
2721                        .stdin(Stdio::null())
2722                        .stdout(Stdio::piped())
2723                        .stderr(Stdio::piped())
2724                        .current_dir(&cwd);
2725
2726                    let mut child = command.spawn().map_err(|err| err.to_string())?;
2727                    let pid = child.id();
2728
2729                    let stdout = child.stdout.take().ok_or("Missing stdout pipe")?;
2730                    let stderr = child.stderr.take().ok_or("Missing stderr pipe")?;
2731
2732                    let stdout_tx = tx.clone();
2733                    let stderr_tx = tx.clone();
2734                    let stdout_handle =
2735                        thread::spawn(move || pump_stream(stdout, &stdout_tx, true));
2736                    let stderr_handle =
2737                        thread::spawn(move || pump_stream(stderr, &stderr_tx, false));
2738
2739                    let start = Instant::now();
2740                    let mut killed = false;
2741                    let status = loop {
2742                        if let Some(status) = child.try_wait().map_err(|err| err.to_string())? {
2743                            break status;
2744                        }
2745
2746                        if cancel_worker.load(AtomicOrdering::SeqCst) {
2747                            killed = true;
2748                            crate::tools::kill_process_tree(Some(pid));
2749                            let _ = child.kill();
2750                            break child.wait().map_err(|err| err.to_string())?;
2751                        }
2752
2753                        if let Some(timeout_ms) = timeout_ms {
2754                            if start.elapsed() >= Duration::from_millis(timeout_ms) {
2755                                killed = true;
2756                                crate::tools::kill_process_tree(Some(pid));
2757                                let _ = child.kill();
2758                                break child.wait().map_err(|err| err.to_string())?;
2759                            }
2760                        }
2761
2762                        thread::sleep(Duration::from_millis(10));
2763                    };
2764
2765                    let stdout_result = stdout_handle
2766                        .join()
2767                        .map_err(|_| "stdout reader thread panicked".to_string())?;
2768                    if let Err(err) = stdout_result {
2769                        return Err(format!("Read stdout: {err}"));
2770                    }
2771
2772                    let stderr_result = stderr_handle
2773                        .join()
2774                        .map_err(|_| "stderr reader thread panicked".to_string())?;
2775                    if let Err(err) = stderr_result {
2776                        return Err(format!("Read stderr: {err}"));
2777                    }
2778
2779                    let code = exit_status_code(status);
2780                    let _ = tx.send(ExecStreamFrame::Final { code, killed });
2781                    Ok(())
2782                })();
2783
2784                if let Err(err) = result {
2785                    if tx.send(ExecStreamFrame::Error(err)).is_err() {
2786                        tracing::trace!(
2787                            call_id = %call_id_for_error,
2788                            "Exec hostcall stream result dropped before completion"
2789                        );
2790                    }
2791                }
2792            });
2793
2794            let _guard = CancelGuard(Arc::clone(&cancel));
2795
2796            let mut sequence = 0_u64;
2797            loop {
2798                if !self.js_runtime().is_hostcall_pending(call_id) {
2799                    cancel.store(true, AtomicOrdering::SeqCst);
2800                    return HostcallOutcome::Error {
2801                        code: "cancelled".to_string(),
2802                        message: "exec stream cancelled".to_string(),
2803                    };
2804                }
2805
2806                match rx.try_recv() {
2807                    Ok(ExecStreamFrame::Stdout(chunk)) => {
2808                        self.js_runtime().complete_hostcall(
2809                            call_id.to_string(),
2810                            HostcallOutcome::StreamChunk {
2811                                sequence,
2812                                chunk: serde_json::json!({ "stdout": chunk }),
2813                                is_final: false,
2814                            },
2815                        );
2816                        sequence = sequence.saturating_add(1);
2817                    }
2818                    Ok(ExecStreamFrame::Stderr(chunk)) => {
2819                        self.js_runtime().complete_hostcall(
2820                            call_id.to_string(),
2821                            HostcallOutcome::StreamChunk {
2822                                sequence,
2823                                chunk: serde_json::json!({ "stderr": chunk }),
2824                                is_final: false,
2825                            },
2826                        );
2827                        sequence = sequence.saturating_add(1);
2828                    }
2829                    Ok(ExecStreamFrame::Final { code, killed }) => {
2830                        return HostcallOutcome::StreamChunk {
2831                            sequence,
2832                            chunk: serde_json::json!({
2833                                "code": code,
2834                                "killed": killed,
2835                            }),
2836                            is_final: true,
2837                        };
2838                    }
2839                    Ok(ExecStreamFrame::Error(message)) => {
2840                        return HostcallOutcome::Error {
2841                            code: "io".to_string(),
2842                            message,
2843                        };
2844                    }
2845                    Err(mpsc::TryRecvError::Empty) => {
2846                        sleep(wall_now(), Duration::from_millis(25)).await;
2847                    }
2848                    Err(mpsc::TryRecvError::Disconnected) => {
2849                        return HostcallOutcome::Error {
2850                            code: "internal".to_string(),
2851                            message: "exec stream channel closed".to_string(),
2852                        };
2853                    }
2854                }
2855            }
2856        }
2857
2858        let cmd = cmd.to_string();
2859        let args = args.clone();
2860        let (tx, rx) = oneshot::channel();
2861        let call_id_for_error = call_id.to_string();
2862
2863        thread::spawn(move || {
2864            #[derive(Clone, Copy)]
2865            enum StreamKind {
2866                Stdout,
2867                Stderr,
2868            }
2869
2870            struct StreamChunk {
2871                kind: StreamKind,
2872                bytes: Vec<u8>,
2873            }
2874
2875            fn pump_stream(
2876                mut reader: impl std::io::Read,
2877                tx: &std::sync::mpsc::SyncSender<StreamChunk>,
2878                kind: StreamKind,
2879            ) {
2880                let mut buf = [0u8; 8192];
2881                loop {
2882                    let read = match reader.read(&mut buf) {
2883                        Ok(0) | Err(_) => break,
2884                        Ok(read) => read,
2885                    };
2886                    let chunk = StreamChunk {
2887                        kind,
2888                        bytes: buf[..read].to_vec(),
2889                    };
2890                    if tx.send(chunk).is_err() {
2891                        break;
2892                    }
2893                }
2894            }
2895
2896            let result: std::result::Result<serde_json::Value, String> = (|| {
2897                let mut command = Command::new(&cmd);
2898                command
2899                    .args(&args)
2900                    .stdin(Stdio::null())
2901                    .stdout(Stdio::piped())
2902                    .stderr(Stdio::piped())
2903                    .current_dir(&cwd);
2904
2905                let mut child = command.spawn().map_err(|err| err.to_string())?;
2906                let pid = child.id();
2907
2908                let stdout = child.stdout.take().ok_or("Missing stdout pipe")?;
2909                let stderr = child.stderr.take().ok_or("Missing stderr pipe")?;
2910
2911                let (tx, rx) = std::sync::mpsc::sync_channel::<StreamChunk>(128);
2912                let tx_stdout = tx.clone();
2913                let _stdout_handle =
2914                    thread::spawn(move || pump_stream(stdout, &tx_stdout, StreamKind::Stdout));
2915                let _stderr_handle =
2916                    thread::spawn(move || pump_stream(stderr, &tx, StreamKind::Stderr));
2917
2918                let start = Instant::now();
2919                let mut killed = false;
2920                let mut stdout_bytes = Vec::new();
2921                let mut stderr_bytes = Vec::new();
2922                let max_bytes = crate::tools::DEFAULT_MAX_BYTES.saturating_mul(2);
2923
2924                let status = loop {
2925                    while let Ok(chunk) = rx.try_recv() {
2926                        match chunk.kind {
2927                            StreamKind::Stdout => {
2928                                if stdout_bytes.len() < max_bytes {
2929                                    stdout_bytes.extend_from_slice(&chunk.bytes);
2930                                }
2931                            }
2932                            StreamKind::Stderr => {
2933                                if stderr_bytes.len() < max_bytes {
2934                                    stderr_bytes.extend_from_slice(&chunk.bytes);
2935                                }
2936                            }
2937                        }
2938                    }
2939
2940                    if let Some(status) = child.try_wait().map_err(|err| err.to_string())? {
2941                        break status;
2942                    }
2943
2944                    if let Some(timeout_ms) = timeout_ms {
2945                        if start.elapsed() >= Duration::from_millis(timeout_ms) {
2946                            killed = true;
2947                            crate::tools::kill_process_tree(Some(pid));
2948                            let _ = child.kill();
2949                            break child.wait().map_err(|err| err.to_string())?;
2950                        }
2951                    }
2952
2953                    thread::sleep(Duration::from_millis(10));
2954                };
2955
2956                let drain_deadline = Instant::now() + Duration::from_secs(2);
2957                loop {
2958                    match rx.try_recv() {
2959                        Ok(chunk) => match chunk.kind {
2960                            StreamKind::Stdout => {
2961                                if stdout_bytes.len() < max_bytes {
2962                                    stdout_bytes.extend_from_slice(&chunk.bytes);
2963                                }
2964                            }
2965                            StreamKind::Stderr => {
2966                                if stderr_bytes.len() < max_bytes {
2967                                    stderr_bytes.extend_from_slice(&chunk.bytes);
2968                                }
2969                            }
2970                        },
2971                        Err(std::sync::mpsc::TryRecvError::Empty) => {
2972                            if Instant::now() >= drain_deadline {
2973                                break;
2974                            }
2975                            thread::sleep(Duration::from_millis(10));
2976                        }
2977                        Err(std::sync::mpsc::TryRecvError::Disconnected) => break,
2978                    }
2979                }
2980
2981                drop(rx); // Close the channel so pump threads exit if blocked
2982
2983                let stdout = String::from_utf8_lossy(&stdout_bytes).to_string();
2984                let stderr = String::from_utf8_lossy(&stderr_bytes).to_string();
2985                let code = exit_status_code(status);
2986
2987                Ok(serde_json::json!({
2988                    "stdout": stdout,
2989                    "stderr": stderr,
2990                    "code": code,
2991                    "killed": killed,
2992                }))
2993            })();
2994
2995            let cx = Cx::for_request();
2996            if tx.send(&cx, result).is_err() {
2997                tracing::trace!(
2998                    call_id = %call_id_for_error,
2999                    "Exec hostcall result dropped before completion"
3000                );
3001            }
3002        });
3003
3004        let cx = Cx::for_request();
3005        match rx.recv(&cx).await {
3006            Ok(Ok(value)) => HostcallOutcome::Success(value),
3007            Ok(Err(err)) => HostcallOutcome::Error {
3008                code: "io".to_string(),
3009                message: err,
3010            },
3011            Err(_) => HostcallOutcome::Error {
3012                code: "internal".to_string(),
3013                message: "exec task cancelled".to_string(),
3014            },
3015        }
3016    }
3017
3018    #[allow(clippy::future_not_send)]
3019    async fn dispatch_http(&self, call_id: &str, payload: serde_json::Value) -> HostcallOutcome {
3020        let call = HostCallPayload {
3021            call_id: call_id.to_string(),
3022            capability: "http".to_string(),
3023            method: "http".to_string(),
3024            params: payload,
3025            timeout_ms: None,
3026            cancel_token: None,
3027            context: None,
3028        };
3029
3030        match self.http_connector.dispatch(&call).await {
3031            Ok(result) => {
3032                if result.is_error {
3033                    let message = result.error.as_ref().map_or_else(
3034                        || "HTTP connector error".to_string(),
3035                        |err| err.message.clone(),
3036                    );
3037                    let code = result
3038                        .error
3039                        .as_ref()
3040                        .map_or("internal", |err| hostcall_code_to_str(err.code));
3041                    HostcallOutcome::Error {
3042                        code: code.to_string(),
3043                        message,
3044                    }
3045                } else {
3046                    HostcallOutcome::Success(result.output)
3047                }
3048            }
3049            Err(err) => HostcallOutcome::Error {
3050                code: "internal".to_string(),
3051                message: err.to_string(),
3052            },
3053        }
3054    }
3055
3056    #[allow(clippy::future_not_send)]
3057    async fn dispatch_session(&self, call_id: &str, op: &str, payload: Value) -> HostcallOutcome {
3058        self.dispatch_session_ref(call_id, op, &payload).await
3059    }
3060
3061    #[allow(clippy::future_not_send, clippy::too_many_lines)]
3062    async fn dispatch_session_ref(
3063        &self,
3064        _call_id: &str,
3065        op: &str,
3066        payload: &Value,
3067    ) -> HostcallOutcome {
3068        use crate::connectors::HostCallErrorCode;
3069
3070        let op_norm = op.trim().to_ascii_lowercase();
3071
3072        // Categorised result: (Value, error_code) where error_code distinguishes taxonomy.
3073        let result: std::result::Result<Value, (HostCallErrorCode, String)> = match op_norm.as_str()
3074        {
3075            "get_state" | "getstate" => Ok(self.session.get_state().await),
3076            "get_messages" | "getmessages" => {
3077                serde_json::to_value(self.session.get_messages().await).map_err(|err| {
3078                    (
3079                        HostCallErrorCode::Internal,
3080                        format!("Serialize messages: {err}"),
3081                    )
3082                })
3083            }
3084            "get_entries" | "getentries" => serde_json::to_value(self.session.get_entries().await)
3085                .map_err(|err| {
3086                    (
3087                        HostCallErrorCode::Internal,
3088                        format!("Serialize entries: {err}"),
3089                    )
3090                }),
3091            "get_branch" | "getbranch" => serde_json::to_value(self.session.get_branch().await)
3092                .map_err(|err| {
3093                    (
3094                        HostCallErrorCode::Internal,
3095                        format!("Serialize branch: {err}"),
3096                    )
3097                }),
3098            "get_file" | "getfile" => {
3099                let state = self.session.get_state().await;
3100                let file = state
3101                    .get("sessionFile")
3102                    .or_else(|| state.get("session_file"))
3103                    .cloned()
3104                    .unwrap_or(Value::Null);
3105                Ok(file)
3106            }
3107            "get_name" | "getname" => {
3108                let state = self.session.get_state().await;
3109                let name = state
3110                    .get("sessionName")
3111                    .or_else(|| state.get("session_name"))
3112                    .cloned()
3113                    .unwrap_or(Value::Null);
3114                Ok(name)
3115            }
3116            "set_name" | "setname" => {
3117                let name = payload
3118                    .get("name")
3119                    .and_then(Value::as_str)
3120                    .unwrap_or_default()
3121                    .to_string();
3122                self.session
3123                    .set_name(name)
3124                    .await
3125                    .map(|()| Value::Null)
3126                    .map_err(|err| (HostCallErrorCode::Io, err.to_string()))
3127            }
3128            "append_entry" | "appendentry" => {
3129                let custom_type = payload
3130                    .get("customType")
3131                    .and_then(Value::as_str)
3132                    .or_else(|| payload.get("custom_type").and_then(Value::as_str))
3133                    .unwrap_or_default()
3134                    .to_string();
3135                let data = payload.get("data").cloned();
3136                self.session
3137                    .append_custom_entry(custom_type, data)
3138                    .await
3139                    .map(|()| Value::Null)
3140                    .map_err(|err| (HostCallErrorCode::Io, err.to_string()))
3141            }
3142            "append_message" | "appendmessage" => {
3143                let message_value = payload
3144                    .get("message")
3145                    .cloned()
3146                    .unwrap_or_else(|| payload.clone());
3147                match serde_json::from_value(message_value) {
3148                    Ok(message) => self
3149                        .session
3150                        .append_message(message)
3151                        .await
3152                        .map(|()| Value::Null)
3153                        .map_err(|err| (HostCallErrorCode::Io, err.to_string())),
3154                    Err(err) => Err((
3155                        HostCallErrorCode::InvalidRequest,
3156                        format!("Parse message: {err}"),
3157                    )),
3158                }
3159            }
3160            "set_model" | "setmodel" => {
3161                let provider = payload
3162                    .get("provider")
3163                    .and_then(Value::as_str)
3164                    .unwrap_or_default()
3165                    .to_string();
3166                let model_id = payload
3167                    .get("modelId")
3168                    .and_then(Value::as_str)
3169                    .or_else(|| payload.get("model_id").and_then(Value::as_str))
3170                    .unwrap_or_default()
3171                    .to_string();
3172                if provider.is_empty() || model_id.is_empty() {
3173                    Err((
3174                        HostCallErrorCode::InvalidRequest,
3175                        "set_model requires 'provider' and 'modelId' fields".to_string(),
3176                    ))
3177                } else {
3178                    self.session
3179                        .set_model(provider, model_id)
3180                        .await
3181                        .map(|()| Value::Bool(true))
3182                        .map_err(|err| (HostCallErrorCode::Io, err.to_string()))
3183                }
3184            }
3185            "get_model" | "getmodel" => {
3186                let (provider, model_id) = self.session.get_model().await;
3187                Ok(serde_json::json!({
3188                    "provider": provider,
3189                    "modelId": model_id,
3190                }))
3191            }
3192            "set_thinking_level" | "setthinkinglevel" => {
3193                let level = payload
3194                    .get("level")
3195                    .and_then(Value::as_str)
3196                    .or_else(|| payload.get("thinkingLevel").and_then(Value::as_str))
3197                    .or_else(|| payload.get("thinking_level").and_then(Value::as_str))
3198                    .unwrap_or_default()
3199                    .to_string();
3200                if level.is_empty() {
3201                    Err((
3202                        HostCallErrorCode::InvalidRequest,
3203                        "set_thinking_level requires 'level' field".to_string(),
3204                    ))
3205                } else {
3206                    self.session
3207                        .set_thinking_level(level)
3208                        .await
3209                        .map(|()| Value::Null)
3210                        .map_err(|err| (HostCallErrorCode::Io, err.to_string()))
3211                }
3212            }
3213            "get_thinking_level" | "getthinkinglevel" => {
3214                let level = self.session.get_thinking_level().await;
3215                Ok(level.map_or(Value::Null, Value::String))
3216            }
3217            "set_label" | "setlabel" => {
3218                let target_id = payload
3219                    .get("targetId")
3220                    .and_then(Value::as_str)
3221                    .or_else(|| payload.get("target_id").and_then(Value::as_str))
3222                    .unwrap_or_default()
3223                    .to_string();
3224                let label = payload
3225                    .get("label")
3226                    .and_then(Value::as_str)
3227                    .map(String::from);
3228                if target_id.is_empty() {
3229                    Err((
3230                        HostCallErrorCode::InvalidRequest,
3231                        "set_label requires 'targetId' field".to_string(),
3232                    ))
3233                } else {
3234                    self.session
3235                        .set_label(target_id, label)
3236                        .await
3237                        .map(|()| Value::Null)
3238                        .map_err(|err| (HostCallErrorCode::Io, err.to_string()))
3239                }
3240            }
3241            _ => Err((
3242                HostCallErrorCode::InvalidRequest,
3243                format!("Unknown session op: {op}"),
3244            )),
3245        };
3246
3247        match result {
3248            Ok(value) => HostcallOutcome::Success(value),
3249            Err((code, message)) => HostcallOutcome::Error {
3250                code: hostcall_code_to_str(code).to_string(),
3251                message,
3252            },
3253        }
3254    }
3255
3256    #[allow(clippy::future_not_send)]
3257    async fn dispatch_ui(
3258        &self,
3259        call_id: &str,
3260        op: &str,
3261        payload: Value,
3262        extension_id: Option<&str>,
3263    ) -> HostcallOutcome {
3264        let op = op.trim();
3265        if op.is_empty() {
3266            return HostcallOutcome::Error {
3267                code: "invalid_request".to_string(),
3268                message: "host_call ui requires non-empty op".to_string(),
3269            };
3270        }
3271
3272        let request = ExtensionUiRequest {
3273            id: call_id.to_string(),
3274            method: op.to_string(),
3275            payload,
3276            timeout_ms: None,
3277            extension_id: extension_id.map(ToString::to_string),
3278        };
3279
3280        match self.ui_handler.request_ui(request).await {
3281            Ok(Some(response)) => HostcallOutcome::Success(ui_response_value_for_op(op, &response)),
3282            Ok(None) => HostcallOutcome::Success(Value::Null),
3283            Err(err) => HostcallOutcome::Error {
3284                code: classify_ui_hostcall_error(&err).to_string(),
3285                message: err.to_string(),
3286            },
3287        }
3288    }
3289
3290    #[allow(clippy::future_not_send)]
3291    async fn dispatch_events(
3292        &self,
3293        call_id: &str,
3294        extension_id: Option<&str>,
3295        op: &str,
3296        payload: Value,
3297    ) -> HostcallOutcome {
3298        self.dispatch_events_ref(call_id, extension_id, op, &payload)
3299            .await
3300    }
3301
3302    #[allow(clippy::future_not_send)]
3303    async fn dispatch_events_ref(
3304        &self,
3305        _call_id: &str,
3306        extension_id: Option<&str>,
3307        op: &str,
3308        payload: &Value,
3309    ) -> HostcallOutcome {
3310        match op.trim() {
3311            "list" => match self.list_extension_events(extension_id).await {
3312                Ok(events) => HostcallOutcome::Success(serde_json::json!({ "events": events })),
3313                Err(err) => HostcallOutcome::Error {
3314                    code: "io".to_string(),
3315                    message: err.to_string(),
3316                },
3317            },
3318            "emit" => {
3319                let event_name = payload
3320                    .get("event")
3321                    .or_else(|| payload.get("name"))
3322                    .and_then(Value::as_str)
3323                    .map(str::trim)
3324                    .filter(|name| !name.is_empty());
3325
3326                let Some(event_name) = event_name else {
3327                    return HostcallOutcome::Error {
3328                        code: "invalid_request".to_string(),
3329                        message: "events.emit requires non-empty `event`".to_string(),
3330                    };
3331                };
3332
3333                let event_payload = payload.get("data").cloned().unwrap_or(Value::Null);
3334                let timeout_ms = payload
3335                    .get("timeout_ms")
3336                    .and_then(Value::as_u64)
3337                    .or_else(|| payload.get("timeoutMs").and_then(Value::as_u64))
3338                    .or_else(|| payload.get("timeout").and_then(Value::as_u64))
3339                    .filter(|ms| *ms > 0)
3340                    .unwrap_or(EXTENSION_EVENT_TIMEOUT_MS);
3341
3342                let ctx_payload = match payload.get("ctx") {
3343                    Some(ctx) => ctx.clone(),
3344                    None => self.build_default_event_ctx(extension_id).await,
3345                };
3346
3347                match Box::pin(self.dispatch_extension_event(
3348                    event_name,
3349                    event_payload,
3350                    ctx_payload,
3351                    timeout_ms,
3352                ))
3353                .await
3354                {
3355                    Ok(result) => {
3356                        let handler_count = self
3357                            .count_event_handlers(event_name)
3358                            .await
3359                            .unwrap_or_default();
3360
3361                        HostcallOutcome::Success(serde_json::json!({
3362                            "dispatched": true,
3363                            "event": event_name,
3364                            "handler_count": handler_count,
3365                            "result": result,
3366                        }))
3367                    }
3368                    Err(err) => HostcallOutcome::Error {
3369                        code: "io".to_string(),
3370                        message: err.to_string(),
3371                    },
3372                }
3373            }
3374            other => HostcallOutcome::Error {
3375                code: "invalid_request".to_string(),
3376                message: format!("Unsupported events op: {other}"),
3377            },
3378        }
3379    }
3380
3381    #[allow(clippy::future_not_send)]
3382    async fn list_extension_events(&self, extension_id: Option<&str>) -> Result<Vec<String>> {
3383        #[derive(serde::Deserialize)]
3384        struct Snapshot {
3385            id: String,
3386            #[serde(default)]
3387            event_hooks: Vec<String>,
3388        }
3389
3390        let json = self
3391            .js_runtime()
3392            .with_ctx(|ctx| {
3393                let global = ctx.globals();
3394                let snapshot_fn: rquickjs::Function<'_> = global.get("__pi_snapshot_extensions")?;
3395                let value: rquickjs::Value<'_> = snapshot_fn.call(())?;
3396                js_to_json(&value)
3397            })
3398            .await?;
3399
3400        let snapshots: Vec<Snapshot> = serde_json::from_value(json)
3401            .map_err(|err| crate::error::Error::extension(err.to_string()))?;
3402
3403        let mut events = BTreeSet::new();
3404        match extension_id {
3405            Some(needle) => {
3406                for snapshot in snapshots {
3407                    if snapshot.id == needle {
3408                        for event in snapshot.event_hooks {
3409                            let event = event.trim();
3410                            if !event.is_empty() {
3411                                events.insert(event.to_string());
3412                            }
3413                        }
3414                        break;
3415                    }
3416                }
3417            }
3418            None => {
3419                for snapshot in snapshots {
3420                    for event in snapshot.event_hooks {
3421                        let event = event.trim();
3422                        if !event.is_empty() {
3423                            events.insert(event.to_string());
3424                        }
3425                    }
3426                }
3427            }
3428        }
3429
3430        Ok(events.into_iter().collect())
3431    }
3432
3433    #[allow(clippy::future_not_send)]
3434    async fn count_event_handlers(&self, event_name: &str) -> Result<Option<usize>> {
3435        let literal = serde_json::to_string(event_name)
3436            .map_err(|err| crate::error::Error::extension(err.to_string()))?;
3437
3438        self.js_runtime()
3439            .with_ctx(|ctx| {
3440                let code = format!(
3441                    "(function() {{ const handlers = (__pi_hook_index.get({literal}) || []); return handlers.length; }})()"
3442                );
3443                ctx.eval::<usize, _>(code)
3444                    .map(Some)
3445                    .or(Ok(None))
3446            })
3447            .await
3448    }
3449
3450    #[allow(clippy::future_not_send)]
3451    async fn build_default_event_ctx(&self, _extension_id: Option<&str>) -> Value {
3452        let entries = self.session.get_entries().await;
3453        let branch = self.session.get_branch().await;
3454        let leaf_entry = branch.last().cloned().unwrap_or(Value::Null);
3455
3456        serde_json::json!({
3457            "hasUI": true,
3458            "cwd": self.cwd.display().to_string(),
3459            "sessionEntries": entries,
3460            "branch": branch,
3461            "leafEntry": leaf_entry,
3462            "modelRegistry": {},
3463        })
3464    }
3465
3466    #[allow(clippy::future_not_send)]
3467    async fn dispatch_extension_event(
3468        &self,
3469        event_name: &str,
3470        event_payload: Value,
3471        ctx_payload: Value,
3472        timeout_ms: u64,
3473    ) -> Result<Value> {
3474        #[derive(serde::Deserialize)]
3475        struct JsTaskError {
3476            #[serde(default)]
3477            code: Option<String>,
3478            message: String,
3479            #[serde(default)]
3480            stack: Option<String>,
3481        }
3482
3483        #[derive(serde::Deserialize)]
3484        struct JsTaskState {
3485            status: String,
3486            #[serde(default)]
3487            value: Option<Value>,
3488            #[serde(default)]
3489            error: Option<JsTaskError>,
3490        }
3491
3492        let task_id = format!("task-events-{call_id}", call_id = uuid::Uuid::new_v4());
3493
3494        self.js_runtime()
3495            .with_ctx(|ctx| {
3496                let global = ctx.globals();
3497                let dispatch_fn: rquickjs::Function<'_> =
3498                    global.get("__pi_dispatch_extension_event")?;
3499                let task_start: rquickjs::Function<'_> = global.get("__pi_task_start")?;
3500
3501                let event_js = json_to_js(&ctx, &event_payload)?;
3502                let ctx_js = json_to_js(&ctx, &ctx_payload)?;
3503                let promise: rquickjs::Value<'_> =
3504                    dispatch_fn.call((event_name.to_string(), event_js, ctx_js))?;
3505                let _task: String = task_start.call((task_id.clone(), promise))?;
3506                Ok(())
3507            })
3508            .await?;
3509
3510        let start = Instant::now();
3511        let timeout = Duration::from_millis(timeout_ms.max(1));
3512
3513        loop {
3514            if start.elapsed() > timeout {
3515                return Err(crate::error::Error::extension(format!(
3516                    "events.emit timed out after {}ms",
3517                    timeout.as_millis()
3518                )));
3519            }
3520
3521            let pending = self.js_runtime().drain_hostcall_requests();
3522            self.dispatch_batch_amac(pending).await;
3523
3524            let _ = self.js_runtime().tick().await?;
3525            let _ = self.js_runtime().drain_microtasks().await?;
3526
3527            let state_json = self
3528                .js_runtime()
3529                .with_ctx(|ctx| {
3530                    let global = ctx.globals();
3531                    let take_fn: rquickjs::Function<'_> = global.get("__pi_task_take")?;
3532                    let value: rquickjs::Value<'_> = take_fn.call((task_id.clone(),))?;
3533                    js_to_json(&value)
3534                })
3535                .await?;
3536
3537            if state_json.is_null() {
3538                return Err(crate::error::Error::extension(
3539                    "events.emit task state missing".to_string(),
3540                ));
3541            }
3542
3543            let state: JsTaskState = serde_json::from_value(state_json)
3544                .map_err(|err| crate::error::Error::extension(err.to_string()))?;
3545
3546            match state.status.as_str() {
3547                "pending" => {
3548                    if !self.js_runtime().has_pending() {
3549                        sleep(wall_now(), Duration::from_millis(1)).await;
3550                    }
3551                }
3552                "resolved" => return Ok(state.value.unwrap_or(Value::Null)),
3553                "rejected" => {
3554                    let err = state.error.unwrap_or_else(|| JsTaskError {
3555                        code: None,
3556                        message: "Unknown JS task error".to_string(),
3557                        stack: None,
3558                    });
3559                    let mut message = err.message;
3560                    if let Some(code) = err.code {
3561                        message = format!("{code}: {message}");
3562                    }
3563                    if let Some(stack) = err.stack {
3564                        if !stack.is_empty() {
3565                            message.push('\n');
3566                            message.push_str(&stack);
3567                        }
3568                    }
3569                    return Err(crate::error::Error::extension(message));
3570                }
3571                other => {
3572                    return Err(crate::error::Error::extension(format!(
3573                        "Unexpected JS task status: {other}"
3574                    )));
3575                }
3576            }
3577
3578            sleep(wall_now(), Duration::from_millis(0)).await;
3579        }
3580    }
3581}
3582
3583const fn hostcall_code_to_str(code: crate::connectors::HostCallErrorCode) -> &'static str {
3584    match code {
3585        crate::connectors::HostCallErrorCode::Timeout => "timeout",
3586        crate::connectors::HostCallErrorCode::Denied => "denied",
3587        crate::connectors::HostCallErrorCode::Io => "io",
3588        crate::connectors::HostCallErrorCode::InvalidRequest => "invalid_request",
3589        crate::connectors::HostCallErrorCode::Internal => "internal",
3590    }
3591}
3592
3593/// Trait for handling individual hostcall types.
3594#[async_trait]
3595pub trait HostcallHandler: Send + Sync {
3596    /// Process a hostcall request and return the outcome.
3597    async fn handle(&self, params: serde_json::Value) -> HostcallOutcome;
3598
3599    /// The capability name for policy checking (e.g., "read", "exec", "http").
3600    fn capability(&self) -> &'static str;
3601}
3602
3603/// Trait for handling UI hostcalls (pi.ui()).
3604#[async_trait]
3605pub trait ExtensionUiHandler: Send + Sync {
3606    async fn request_ui(&self, request: ExtensionUiRequest) -> Result<Option<ExtensionUiResponse>>;
3607}
3608
3609#[cfg(test)]
3610#[allow(clippy::arc_with_non_send_sync)]
3611mod tests {
3612    use super::*;
3613
3614    use crate::connectors::http::HttpConnectorConfig;
3615    use crate::error::Error;
3616    use crate::extensions::{
3617        ExtensionBody, ExtensionMessage, ExtensionOverride, ExtensionPolicyMode, HostCallPayload,
3618        PROTOCOL_VERSION, PolicyProfile,
3619    };
3620    use crate::scheduler::DeterministicClock;
3621    use crate::session::SessionMessage;
3622    use serde_json::Value;
3623    use std::collections::HashMap;
3624    use std::io::{Read, Write};
3625    use std::net::TcpListener;
3626    use std::path::Path;
3627    use std::sync::Mutex;
3628
3629    #[test]
3630    fn ui_confirm_cancel_defaults_to_false() {
3631        let response = ExtensionUiResponse {
3632            id: "req-1".to_string(),
3633            value: None,
3634            cancelled: true,
3635        };
3636        assert_eq!(
3637            ui_response_value_for_op("confirm", &response),
3638            Value::Bool(false)
3639        );
3640        assert_eq!(ui_response_value_for_op("select", &response), Value::Null);
3641    }
3642
3643    #[test]
3644    fn policy_snapshot_version_is_deterministic_for_equivalent_policies() {
3645        let mut policy_a = ExtensionPolicy::default();
3646        let mut override_a = ExtensionOverride::default();
3647        override_a.allow.push("exec".to_string());
3648        policy_a
3649            .per_extension
3650            .insert("ext.alpha".to_string(), override_a.clone());
3651        policy_a
3652            .per_extension
3653            .insert("ext.beta".to_string(), override_a);
3654
3655        let mut policy_b = ExtensionPolicy::default();
3656        let mut override_b = ExtensionOverride::default();
3657        override_b.allow.push("exec".to_string());
3658        // Insert in reverse order to verify canonical hashing is order-insensitive.
3659        policy_b
3660            .per_extension
3661            .insert("ext.beta".to_string(), override_b.clone());
3662        policy_b
3663            .per_extension
3664            .insert("ext.alpha".to_string(), override_b);
3665
3666        assert_eq!(
3667            policy_snapshot_version(&policy_a),
3668            policy_snapshot_version(&policy_b)
3669        );
3670    }
3671
3672    #[test]
3673    fn policy_snapshot_version_changes_on_material_policy_delta() {
3674        let policy_base = ExtensionPolicy::from_profile(PolicyProfile::Standard);
3675        let mut policy_delta = policy_base.clone();
3676        policy_delta.deny_caps.push("http".to_string());
3677
3678        assert_ne!(
3679            policy_snapshot_version(&policy_base),
3680            policy_snapshot_version(&policy_delta)
3681        );
3682    }
3683
3684    #[test]
3685    fn policy_lookup_path_marks_known_vs_fallback_capabilities() {
3686        assert_eq!(policy_lookup_path("read"), "policy_snapshot_table");
3687        assert_eq!(policy_lookup_path("READ"), "policy_snapshot_table");
3688        assert_eq!(
3689            policy_lookup_path("non_standard_custom_capability"),
3690            "policy_snapshot_fallback"
3691        );
3692    }
3693
3694    #[test]
3695    fn policy_snapshot_lookup_swaps_decision_across_profile_change() {
3696        let safe_policy = ExtensionPolicy::from_profile(PolicyProfile::Safe);
3697        let permissive_policy = ExtensionPolicy::from_profile(PolicyProfile::Permissive);
3698
3699        let safe_snapshot = PolicySnapshot::compile(&safe_policy);
3700        let permissive_snapshot = PolicySnapshot::compile(&permissive_policy);
3701
3702        let safe_first = safe_snapshot.lookup("exec", Some("ext.swap"));
3703        let safe_second = safe_snapshot.lookup("EXEC", Some("ext.swap"));
3704        assert_eq!(safe_first.decision, PolicyDecision::Deny);
3705        assert_eq!(safe_first.decision, safe_second.decision);
3706
3707        let permissive_first = permissive_snapshot.lookup("exec", Some("ext.swap"));
3708        let permissive_second = permissive_snapshot.lookup("EXEC", Some("ext.swap"));
3709        assert_eq!(permissive_first.decision, PolicyDecision::Allow);
3710        assert_eq!(permissive_first.decision, permissive_second.decision);
3711    }
3712
3713    struct NullSession;
3714
3715    #[async_trait]
3716    impl ExtensionSession for NullSession {
3717        async fn get_state(&self) -> Value {
3718            Value::Null
3719        }
3720
3721        async fn get_messages(&self) -> Vec<SessionMessage> {
3722            Vec::new()
3723        }
3724
3725        async fn get_entries(&self) -> Vec<Value> {
3726            Vec::new()
3727        }
3728
3729        async fn get_branch(&self) -> Vec<Value> {
3730            Vec::new()
3731        }
3732
3733        async fn set_name(&self, _name: String) -> Result<()> {
3734            Ok(())
3735        }
3736
3737        async fn append_message(&self, _message: SessionMessage) -> Result<()> {
3738            Ok(())
3739        }
3740
3741        async fn append_custom_entry(
3742            &self,
3743            _custom_type: String,
3744            _data: Option<Value>,
3745        ) -> Result<()> {
3746            Ok(())
3747        }
3748
3749        async fn set_model(&self, _provider: String, _model_id: String) -> Result<()> {
3750            Ok(())
3751        }
3752
3753        async fn get_model(&self) -> (Option<String>, Option<String>) {
3754            (None, None)
3755        }
3756
3757        async fn set_thinking_level(&self, _level: String) -> Result<()> {
3758            Ok(())
3759        }
3760
3761        async fn get_thinking_level(&self) -> Option<String> {
3762            None
3763        }
3764
3765        async fn set_label(&self, _target_id: String, _label: Option<String>) -> Result<()> {
3766            Ok(())
3767        }
3768    }
3769
3770    struct NullUiHandler;
3771
3772    #[async_trait]
3773    impl ExtensionUiHandler for NullUiHandler {
3774        async fn request_ui(
3775            &self,
3776            _request: ExtensionUiRequest,
3777        ) -> Result<Option<ExtensionUiResponse>> {
3778            Ok(None)
3779        }
3780    }
3781
3782    struct TestUiHandler {
3783        captured: Arc<Mutex<Vec<ExtensionUiRequest>>>,
3784        response_value: Value,
3785    }
3786
3787    #[async_trait]
3788    impl ExtensionUiHandler for TestUiHandler {
3789        async fn request_ui(
3790            &self,
3791            request: ExtensionUiRequest,
3792        ) -> Result<Option<ExtensionUiResponse>> {
3793            self.captured.lock().unwrap().push(request.clone());
3794            Ok(Some(ExtensionUiResponse {
3795                id: request.id,
3796                value: Some(self.response_value.clone()),
3797                cancelled: false,
3798            }))
3799        }
3800    }
3801
3802    type CustomEntry = (String, Option<Value>);
3803    type CustomEntries = Arc<Mutex<Vec<CustomEntry>>>;
3804
3805    type LabelEntry = (String, Option<String>);
3806
3807    struct TestSession {
3808        state: Arc<Mutex<Value>>,
3809        messages: Arc<Mutex<Vec<SessionMessage>>>,
3810        entries: Arc<Mutex<Vec<Value>>>,
3811        branch: Arc<Mutex<Vec<Value>>>,
3812        name: Arc<Mutex<Option<String>>>,
3813        custom_entries: CustomEntries,
3814        labels: Arc<Mutex<Vec<LabelEntry>>>,
3815    }
3816
3817    #[async_trait]
3818    impl ExtensionSession for TestSession {
3819        async fn get_state(&self) -> Value {
3820            self.state.lock().unwrap().clone()
3821        }
3822
3823        async fn get_messages(&self) -> Vec<SessionMessage> {
3824            self.messages.lock().unwrap().clone()
3825        }
3826
3827        async fn get_entries(&self) -> Vec<Value> {
3828            self.entries.lock().unwrap().clone()
3829        }
3830
3831        async fn get_branch(&self) -> Vec<Value> {
3832            self.branch.lock().unwrap().clone()
3833        }
3834
3835        async fn set_name(&self, name: String) -> Result<()> {
3836            {
3837                let mut guard = self.name.lock().unwrap();
3838                *guard = Some(name.clone());
3839            }
3840            let mut state = self.state.lock().unwrap();
3841            if let Value::Object(ref mut map) = *state {
3842                map.insert("sessionName".to_string(), Value::String(name));
3843            }
3844            drop(state);
3845            Ok(())
3846        }
3847
3848        async fn append_message(&self, message: SessionMessage) -> Result<()> {
3849            self.messages.lock().unwrap().push(message);
3850            Ok(())
3851        }
3852
3853        async fn append_custom_entry(
3854            &self,
3855            custom_type: String,
3856            data: Option<Value>,
3857        ) -> Result<()> {
3858            self.custom_entries
3859                .lock()
3860                .unwrap()
3861                .push((custom_type, data));
3862            Ok(())
3863        }
3864
3865        async fn set_model(&self, provider: String, model_id: String) -> Result<()> {
3866            let mut state = self.state.lock().unwrap();
3867            if let Value::Object(ref mut map) = *state {
3868                map.insert("provider".to_string(), Value::String(provider));
3869                map.insert("modelId".to_string(), Value::String(model_id));
3870            }
3871            drop(state);
3872            Ok(())
3873        }
3874
3875        async fn get_model(&self) -> (Option<String>, Option<String>) {
3876            let state = self.state.lock().unwrap();
3877            let provider = state
3878                .get("provider")
3879                .and_then(Value::as_str)
3880                .map(String::from);
3881            let model_id = state
3882                .get("modelId")
3883                .and_then(Value::as_str)
3884                .map(String::from);
3885            drop(state);
3886            (provider, model_id)
3887        }
3888
3889        async fn set_thinking_level(&self, level: String) -> Result<()> {
3890            let mut state = self.state.lock().unwrap();
3891            if let Value::Object(ref mut map) = *state {
3892                map.insert("thinkingLevel".to_string(), Value::String(level));
3893            }
3894            drop(state);
3895            Ok(())
3896        }
3897
3898        async fn get_thinking_level(&self) -> Option<String> {
3899            let state = self.state.lock().unwrap();
3900            let level = state
3901                .get("thinkingLevel")
3902                .and_then(Value::as_str)
3903                .map(String::from);
3904            drop(state);
3905            level
3906        }
3907
3908        async fn set_label(&self, target_id: String, label: Option<String>) -> Result<()> {
3909            self.labels.lock().unwrap().push((target_id, label));
3910            Ok(())
3911        }
3912    }
3913
3914    fn build_dispatcher(
3915        runtime: Rc<PiJsRuntime<DeterministicClock>>,
3916    ) -> ExtensionDispatcher<DeterministicClock> {
3917        build_dispatcher_with_policy(
3918            runtime,
3919            ExtensionPolicy::from_profile(PolicyProfile::Permissive),
3920        )
3921    }
3922
3923    fn build_dispatcher_with_policy(
3924        runtime: Rc<PiJsRuntime<DeterministicClock>>,
3925        policy: ExtensionPolicy,
3926    ) -> ExtensionDispatcher<DeterministicClock> {
3927        ExtensionDispatcher::new_with_policy(
3928            runtime,
3929            Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
3930            Arc::new(HttpConnector::with_defaults()),
3931            Arc::new(NullSession),
3932            Arc::new(NullUiHandler),
3933            PathBuf::from("."),
3934            policy,
3935        )
3936    }
3937
3938    fn build_dispatcher_with_policy_and_oracle(
3939        runtime: Rc<PiJsRuntime<DeterministicClock>>,
3940        policy: ExtensionPolicy,
3941        oracle_config: DualExecOracleConfig,
3942    ) -> ExtensionDispatcher<DeterministicClock> {
3943        ExtensionDispatcher::new_with_policy_and_oracle_config(
3944            runtime,
3945            Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
3946            Arc::new(HttpConnector::with_defaults()),
3947            Arc::new(NullSession),
3948            Arc::new(NullUiHandler),
3949            PathBuf::from("."),
3950            policy,
3951            oracle_config,
3952        )
3953    }
3954
3955    fn spawn_http_server(body: &'static str) -> std::net::SocketAddr {
3956        let listener = TcpListener::bind("127.0.0.1:0").expect("bind http server");
3957        let addr = listener.local_addr().expect("server addr");
3958        thread::spawn(move || {
3959            if let Ok((mut stream, _)) = listener.accept() {
3960                let mut buf = [0u8; 1024];
3961                let _ = stream.read(&mut buf);
3962                let response = format!(
3963                    "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: text/plain\r\n\r\n{}",
3964                    body.len(),
3965                    body
3966                );
3967                let _ = stream.write_all(response.as_bytes());
3968            }
3969        });
3970        addr
3971    }
3972
3973    #[test]
3974    fn dispatcher_constructs() {
3975        futures::executor::block_on(async {
3976            let runtime = Rc::new(
3977                PiJsRuntime::with_clock(DeterministicClock::new(0))
3978                    .await
3979                    .expect("runtime"),
3980            );
3981            let dispatcher = build_dispatcher(Rc::clone(&runtime));
3982            assert!(std::ptr::eq(
3983                dispatcher.runtime.as_js_runtime(),
3984                runtime.as_ref()
3985            ));
3986            assert_eq!(dispatcher.cwd, PathBuf::from("."));
3987        });
3988    }
3989
3990    #[test]
3991    fn dispatcher_drains_empty_queue() {
3992        futures::executor::block_on(async {
3993            let runtime = Rc::new(
3994                PiJsRuntime::with_clock(DeterministicClock::new(0))
3995                    .await
3996                    .expect("runtime"),
3997            );
3998            let dispatcher = build_dispatcher(Rc::clone(&runtime));
3999            let drained = dispatcher.drain_hostcall_requests();
4000            assert!(drained.is_empty());
4001        });
4002    }
4003
4004    #[test]
4005    fn dispatcher_drains_runtime_requests() {
4006        futures::executor::block_on(async {
4007            let runtime = Rc::new(
4008                PiJsRuntime::with_clock(DeterministicClock::new(0))
4009                    .await
4010                    .expect("runtime"),
4011            );
4012            runtime
4013                .eval(r#"pi.tool("read", { "path": "test.txt" });"#)
4014                .await
4015                .expect("eval");
4016
4017            let dispatcher = build_dispatcher(Rc::clone(&runtime));
4018            let drained = dispatcher.drain_hostcall_requests();
4019            assert_eq!(drained.len(), 1);
4020        });
4021    }
4022
4023    #[test]
4024    fn dispatcher_tool_hostcall_executes_and_resolves_promise() {
4025        futures::executor::block_on(async {
4026            let temp_dir = tempfile::tempdir().expect("tempdir");
4027            std::fs::write(temp_dir.path().join("test.txt"), "hello world").expect("write file");
4028
4029            let runtime = Rc::new(
4030                PiJsRuntime::with_clock(DeterministicClock::new(0))
4031                    .await
4032                    .expect("runtime"),
4033            );
4034            runtime
4035                .eval(
4036                    r#"
4037                    globalThis.result = null;
4038                    pi.tool("read", { path: "test.txt" }).then((r) => { globalThis.result = r; });
4039                "#,
4040                )
4041                .await
4042                .expect("eval");
4043
4044            let requests = runtime.drain_hostcall_requests();
4045            assert_eq!(requests.len(), 1);
4046
4047            let dispatcher = ExtensionDispatcher::new(
4048                Rc::clone(&runtime),
4049                Arc::new(ToolRegistry::new(&["read"], temp_dir.path(), None)),
4050                Arc::new(HttpConnector::with_defaults()),
4051                Arc::new(NullSession),
4052                Arc::new(NullUiHandler),
4053                temp_dir.path().to_path_buf(),
4054            );
4055
4056            for request in requests {
4057                dispatcher.dispatch_and_complete(request).await;
4058            }
4059
4060            let stats = runtime.tick().await.expect("tick");
4061            assert!(stats.ran_macrotask);
4062
4063            runtime
4064                .eval(
4065                    r#"
4066                    if (globalThis.result === null) throw new Error("Promise not resolved");
4067                    if (!JSON.stringify(globalThis.result).includes("hello world")) {
4068                        throw new Error("Wrong result: " + JSON.stringify(globalThis.result));
4069                    }
4070                "#,
4071                )
4072                .await
4073                .expect("verify result");
4074        });
4075    }
4076
4077    #[test]
4078    fn dispatcher_tool_hostcall_unknown_tool_rejects_promise() {
4079        futures::executor::block_on(async {
4080            let runtime = Rc::new(
4081                PiJsRuntime::with_clock(DeterministicClock::new(0))
4082                    .await
4083                    .expect("runtime"),
4084            );
4085            runtime
4086                .eval(
4087                    r#"
4088                    globalThis.err = null;
4089                    pi.tool("nope", {}).catch((e) => { globalThis.err = e.code; });
4090                "#,
4091                )
4092                .await
4093                .expect("eval");
4094
4095            let requests = runtime.drain_hostcall_requests();
4096            assert_eq!(requests.len(), 1);
4097
4098            let dispatcher = build_dispatcher(Rc::clone(&runtime));
4099            for request in requests {
4100                dispatcher.dispatch_and_complete(request).await;
4101            }
4102
4103            while runtime.has_pending() {
4104                runtime.tick().await.expect("tick");
4105                runtime.drain_microtasks().await.expect("microtasks");
4106            }
4107
4108            runtime
4109                .eval(
4110                    r#"
4111                    if (globalThis.err === null) throw new Error("Promise not rejected");
4112                    if (globalThis.err !== "invalid_request") {
4113                        throw new Error("Wrong error code: " + globalThis.err);
4114                    }
4115                "#,
4116                )
4117                .await
4118                .expect("verify error");
4119        });
4120    }
4121
4122    #[test]
4123    fn dispatcher_session_hostcall_resolves_state_and_set_name() {
4124        futures::executor::block_on(async {
4125            let runtime = Rc::new(
4126                PiJsRuntime::with_clock(DeterministicClock::new(0))
4127                    .await
4128                    .expect("runtime"),
4129            );
4130
4131            runtime
4132                .eval(
4133                    r#"
4134                    globalThis.state = null;
4135                    globalThis.file = null;
4136                    globalThis.nameValue = null;
4137                    globalThis.nameSet = false;
4138                    pi.session("get_state", {}).then((r) => { globalThis.state = r; });
4139                    pi.session("get_file", {}).then((r) => { globalThis.file = r; });
4140                    pi.session("get_name", {}).then((r) => { globalThis.nameValue = r; });
4141                    pi.session("set_name", { name: "hello" }).then(() => { globalThis.nameSet = true; });
4142                "#,
4143                )
4144                .await
4145                .expect("eval");
4146
4147            let requests = runtime.drain_hostcall_requests();
4148            assert_eq!(requests.len(), 4);
4149
4150            let name = Arc::new(Mutex::new(None));
4151            let state = Arc::new(Mutex::new(serde_json::json!({
4152                "sessionFile": "/tmp/session.jsonl",
4153                "sessionName": "demo",
4154            })));
4155            let session = Arc::new(TestSession {
4156                state: Arc::clone(&state),
4157                messages: Arc::new(Mutex::new(Vec::new())),
4158                entries: Arc::new(Mutex::new(Vec::new())),
4159                branch: Arc::new(Mutex::new(Vec::new())),
4160                name: Arc::clone(&name),
4161                custom_entries: Arc::new(Mutex::new(Vec::new())),
4162                labels: Arc::new(Mutex::new(Vec::new())),
4163            });
4164
4165            let dispatcher = ExtensionDispatcher::new(
4166                Rc::clone(&runtime),
4167                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
4168                Arc::new(HttpConnector::with_defaults()),
4169                session,
4170                Arc::new(NullUiHandler),
4171                PathBuf::from("."),
4172            );
4173
4174            for request in requests {
4175                dispatcher.dispatch_and_complete(request).await;
4176            }
4177
4178            while runtime.has_pending() {
4179                runtime.tick().await.expect("tick");
4180                runtime.drain_microtasks().await.expect("microtasks");
4181            }
4182
4183            let (state_value, file_value, name_value, name_set) = runtime
4184                .with_ctx(|ctx| {
4185                    let global = ctx.globals();
4186                    let state_js: rquickjs::Value<'_> = global.get("state")?;
4187                    let file_js: rquickjs::Value<'_> = global.get("file")?;
4188                    let name_js: rquickjs::Value<'_> = global.get("nameValue")?;
4189                    let name_set_js: rquickjs::Value<'_> = global.get("nameSet")?;
4190                    Ok((
4191                        crate::extensions_js::js_to_json(&state_js)?,
4192                        crate::extensions_js::js_to_json(&file_js)?,
4193                        crate::extensions_js::js_to_json(&name_js)?,
4194                        crate::extensions_js::js_to_json(&name_set_js)?,
4195                    ))
4196                })
4197                .await
4198                .expect("read globals");
4199
4200            let state_file = state_value
4201                .get("sessionFile")
4202                .and_then(Value::as_str)
4203                .unwrap_or_default();
4204            assert_eq!(state_file, "/tmp/session.jsonl");
4205            assert_eq!(file_value, Value::String("/tmp/session.jsonl".to_string()));
4206            assert_eq!(name_value, Value::String("demo".to_string()));
4207            assert_eq!(name_set, Value::Bool(true));
4208
4209            let name_value = name.lock().unwrap().clone();
4210            assert_eq!(name_value.as_deref(), Some("hello"));
4211        });
4212    }
4213
4214    #[test]
4215    fn dispatcher_session_hostcall_get_messages_entries_branch() {
4216        futures::executor::block_on(async {
4217            let runtime = Rc::new(
4218                PiJsRuntime::with_clock(DeterministicClock::new(0))
4219                    .await
4220                    .expect("runtime"),
4221            );
4222
4223            runtime
4224                .eval(
4225                    r#"
4226                    globalThis.messages = null;
4227                    globalThis.entries = null;
4228                    globalThis.branch = null;
4229                    pi.session("get_messages", {}).then((r) => { globalThis.messages = r; });
4230                    pi.session("get_entries", {}).then((r) => { globalThis.entries = r; });
4231                    pi.session("get_branch", {}).then((r) => { globalThis.branch = r; });
4232                "#,
4233                )
4234                .await
4235                .expect("eval");
4236
4237            let requests = runtime.drain_hostcall_requests();
4238            assert_eq!(requests.len(), 3);
4239
4240            let message = SessionMessage::Custom {
4241                custom_type: "note".to_string(),
4242                content: "hello".to_string(),
4243                display: true,
4244                details: None,
4245                timestamp: Some(0),
4246            };
4247            let entries = vec![serde_json::json!({ "id": "entry-1", "type": "custom" })];
4248            let branch = vec![serde_json::json!({ "id": "entry-2", "type": "branch" })];
4249
4250            let session = Arc::new(TestSession {
4251                state: Arc::new(Mutex::new(Value::Null)),
4252                messages: Arc::new(Mutex::new(vec![message.clone()])),
4253                entries: Arc::new(Mutex::new(entries.clone())),
4254                branch: Arc::new(Mutex::new(branch.clone())),
4255                name: Arc::new(Mutex::new(None)),
4256                custom_entries: Arc::new(Mutex::new(Vec::new())),
4257                labels: Arc::new(Mutex::new(Vec::new())),
4258            });
4259
4260            let dispatcher = ExtensionDispatcher::new(
4261                Rc::clone(&runtime),
4262                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
4263                Arc::new(HttpConnector::with_defaults()),
4264                session,
4265                Arc::new(NullUiHandler),
4266                PathBuf::from("."),
4267            );
4268
4269            for request in requests {
4270                dispatcher.dispatch_and_complete(request).await;
4271            }
4272
4273            while runtime.has_pending() {
4274                runtime.tick().await.expect("tick");
4275                runtime.drain_microtasks().await.expect("microtasks");
4276            }
4277
4278            let (messages_value, entries_value, branch_value) = runtime
4279                .with_ctx(|ctx| {
4280                    let global = ctx.globals();
4281                    let messages_js: rquickjs::Value<'_> = global.get("messages")?;
4282                    let entries_js: rquickjs::Value<'_> = global.get("entries")?;
4283                    let branch_js: rquickjs::Value<'_> = global.get("branch")?;
4284                    Ok((
4285                        crate::extensions_js::js_to_json(&messages_js)?,
4286                        crate::extensions_js::js_to_json(&entries_js)?,
4287                        crate::extensions_js::js_to_json(&branch_js)?,
4288                    ))
4289                })
4290                .await
4291                .expect("read globals");
4292
4293            let messages_array = messages_value.as_array().expect("messages array");
4294            assert_eq!(messages_array.len(), 1);
4295            assert_eq!(
4296                messages_array[0]
4297                    .get("role")
4298                    .and_then(Value::as_str)
4299                    .unwrap_or_default(),
4300                "custom"
4301            );
4302            assert_eq!(
4303                messages_array[0]
4304                    .get("customType")
4305                    .and_then(Value::as_str)
4306                    .unwrap_or_default(),
4307                "note"
4308            );
4309            assert_eq!(entries_value, Value::Array(entries));
4310            assert_eq!(branch_value, Value::Array(branch));
4311        });
4312    }
4313
4314    #[test]
4315    fn dispatcher_session_hostcall_append_message_and_entry() {
4316        futures::executor::block_on(async {
4317            let runtime = Rc::new(
4318                PiJsRuntime::with_clock(DeterministicClock::new(0))
4319                    .await
4320                    .expect("runtime"),
4321            );
4322
4323            runtime
4324                .eval(
4325                    r#"
4326                    globalThis.messageAppended = false;
4327                    globalThis.entryAppended = false;
4328                    pi.session("append_message", {
4329                        message: { role: "custom", customType: "note", content: "hi", display: true }
4330                    }).then(() => { globalThis.messageAppended = true; });
4331                    pi.session("append_entry", {
4332                        customType: "meta",
4333                        data: { ok: true }
4334                    }).then(() => { globalThis.entryAppended = true; });
4335                "#,
4336                )
4337                .await
4338                .expect("eval");
4339
4340            let requests = runtime.drain_hostcall_requests();
4341            assert_eq!(requests.len(), 2);
4342
4343            let session = Arc::new(TestSession {
4344                state: Arc::new(Mutex::new(Value::Null)),
4345                messages: Arc::new(Mutex::new(Vec::new())),
4346                entries: Arc::new(Mutex::new(Vec::new())),
4347                branch: Arc::new(Mutex::new(Vec::new())),
4348                name: Arc::new(Mutex::new(None)),
4349                custom_entries: Arc::new(Mutex::new(Vec::new())),
4350                labels: Arc::new(Mutex::new(Vec::new())),
4351            });
4352
4353            let dispatcher = ExtensionDispatcher::new(
4354                Rc::clone(&runtime),
4355                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
4356                Arc::new(HttpConnector::with_defaults()),
4357                {
4358                    let session_handle: Arc<dyn ExtensionSession + Send + Sync> = session.clone();
4359                    session_handle
4360                },
4361                Arc::new(NullUiHandler),
4362                PathBuf::from("."),
4363            );
4364
4365            for request in requests {
4366                dispatcher.dispatch_and_complete(request).await;
4367            }
4368
4369            while runtime.has_pending() {
4370                runtime.tick().await.expect("tick");
4371                runtime.drain_microtasks().await.expect("microtasks");
4372            }
4373
4374            let (message_appended, entry_appended) = runtime
4375                .with_ctx(|ctx| {
4376                    let global = ctx.globals();
4377                    let message_js: rquickjs::Value<'_> = global.get("messageAppended")?;
4378                    let entry_js: rquickjs::Value<'_> = global.get("entryAppended")?;
4379                    Ok((
4380                        crate::extensions_js::js_to_json(&message_js)?,
4381                        crate::extensions_js::js_to_json(&entry_js)?,
4382                    ))
4383                })
4384                .await
4385                .expect("read globals");
4386
4387            assert_eq!(message_appended, Value::Bool(true));
4388            assert_eq!(entry_appended, Value::Bool(true));
4389
4390            {
4391                let messages = session.messages.lock().unwrap().clone();
4392                assert_eq!(messages.len(), 1);
4393                match &messages[0] {
4394                    SessionMessage::Custom {
4395                        custom_type,
4396                        content,
4397                        display,
4398                        ..
4399                    } => {
4400                        assert_eq!(custom_type, "note");
4401                        assert_eq!(content, "hi");
4402                        assert!(*display);
4403                    }
4404                    other => assert!(
4405                        matches!(other, SessionMessage::Custom { .. }),
4406                        "Unexpected message: {other:?}"
4407                    ),
4408                }
4409            }
4410
4411            {
4412                let expected = Some(serde_json::json!({ "ok": true }));
4413                let custom_entries = session.custom_entries.lock().unwrap().clone();
4414                assert_eq!(custom_entries.len(), 1);
4415                assert_eq!(custom_entries[0].0, "meta");
4416                assert_eq!(custom_entries[0].1, expected);
4417                drop(custom_entries);
4418            }
4419        });
4420    }
4421
4422    #[test]
4423    fn dispatcher_session_hostcall_unknown_op_rejects_promise() {
4424        futures::executor::block_on(async {
4425            let runtime = Rc::new(
4426                PiJsRuntime::with_clock(DeterministicClock::new(0))
4427                    .await
4428                    .expect("runtime"),
4429            );
4430
4431            runtime
4432                .eval(
4433                    r#"
4434                    globalThis.err = null;
4435                    pi.session("nope", {}).catch((e) => { globalThis.err = e.code; });
4436                "#,
4437                )
4438                .await
4439                .expect("eval");
4440
4441            let requests = runtime.drain_hostcall_requests();
4442            assert_eq!(requests.len(), 1);
4443
4444            let dispatcher = build_dispatcher(Rc::clone(&runtime));
4445            for request in requests {
4446                dispatcher.dispatch_and_complete(request).await;
4447            }
4448
4449            while runtime.has_pending() {
4450                runtime.tick().await.expect("tick");
4451                runtime.drain_microtasks().await.expect("microtasks");
4452            }
4453
4454            let err_value = runtime
4455                .with_ctx(|ctx| {
4456                    let global = ctx.globals();
4457                    let err_js: rquickjs::Value<'_> = global.get("err")?;
4458                    crate::extensions_js::js_to_json(&err_js)
4459                })
4460                .await
4461                .expect("read globals");
4462
4463            assert_eq!(err_value, Value::String("invalid_request".to_string()));
4464        });
4465    }
4466
4467    #[test]
4468    fn dispatcher_session_hostcall_append_message_invalid_rejects_promise() {
4469        futures::executor::block_on(async {
4470            let runtime = Rc::new(
4471                PiJsRuntime::with_clock(DeterministicClock::new(0))
4472                    .await
4473                    .expect("runtime"),
4474            );
4475
4476            runtime
4477                .eval(
4478                    r#"
4479                    globalThis.err = null;
4480                    pi.session("append_message", { message: { nope: 1 } })
4481                        .catch((e) => { globalThis.err = e.code; });
4482                "#,
4483                )
4484                .await
4485                .expect("eval");
4486
4487            let requests = runtime.drain_hostcall_requests();
4488            assert_eq!(requests.len(), 1);
4489
4490            let dispatcher = build_dispatcher(Rc::clone(&runtime));
4491            for request in requests {
4492                dispatcher.dispatch_and_complete(request).await;
4493            }
4494
4495            while runtime.has_pending() {
4496                runtime.tick().await.expect("tick");
4497                runtime.drain_microtasks().await.expect("microtasks");
4498            }
4499
4500            let err_value = runtime
4501                .with_ctx(|ctx| {
4502                    let global = ctx.globals();
4503                    let err_js: rquickjs::Value<'_> = global.get("err")?;
4504                    crate::extensions_js::js_to_json(&err_js)
4505                })
4506                .await
4507                .expect("read globals");
4508
4509            assert_eq!(err_value, Value::String("invalid_request".to_string()));
4510        });
4511    }
4512
4513    #[test]
4514    #[cfg(unix)]
4515    fn dispatcher_exec_hostcall_executes_and_resolves_promise() {
4516        futures::executor::block_on(async {
4517            let runtime = Rc::new(
4518                PiJsRuntime::with_clock(DeterministicClock::new(0))
4519                    .await
4520                    .expect("runtime"),
4521            );
4522
4523            runtime
4524                .eval(
4525                    r#"
4526                    globalThis.result = null;
4527                    pi.exec("sh", ["-c", "printf hello"], {})
4528                        .then((r) => { globalThis.result = r; });
4529                "#,
4530                )
4531                .await
4532                .expect("eval");
4533
4534            let requests = runtime.drain_hostcall_requests();
4535            assert_eq!(requests.len(), 1);
4536
4537            let dispatcher = build_dispatcher(Rc::clone(&runtime));
4538            for request in requests {
4539                dispatcher.dispatch_and_complete(request).await;
4540            }
4541
4542            runtime.tick().await.expect("tick");
4543
4544            runtime
4545                .eval(
4546                    r#"
4547                    if (globalThis.result === null) throw new Error("Promise not resolved");
4548                    if (globalThis.result.stdout !== "hello") {
4549                        throw new Error("Wrong stdout: " + JSON.stringify(globalThis.result));
4550                    }
4551                    if (globalThis.result.code !== 0) {
4552                        throw new Error("Wrong exit code: " + JSON.stringify(globalThis.result));
4553                    }
4554                    if (globalThis.result.killed !== false) {
4555                        throw new Error("Unexpected killed flag: " + JSON.stringify(globalThis.result));
4556                    }
4557                "#,
4558                )
4559                .await
4560                .expect("verify result");
4561        });
4562    }
4563
4564    #[test]
4565    #[cfg(unix)]
4566    fn dispatcher_exec_hostcall_command_not_found_rejects_promise() {
4567        futures::executor::block_on(async {
4568            let runtime = Rc::new(
4569                PiJsRuntime::with_clock(DeterministicClock::new(0))
4570                    .await
4571                    .expect("runtime"),
4572            );
4573
4574            runtime
4575                .eval(
4576                    r#"
4577                    globalThis.err = null;
4578                    pi.exec("definitely_not_a_real_command", [], {})
4579                        .catch((e) => { globalThis.err = e.code; });
4580                "#,
4581                )
4582                .await
4583                .expect("eval");
4584
4585            let requests = runtime.drain_hostcall_requests();
4586            assert_eq!(requests.len(), 1);
4587
4588            let dispatcher = build_dispatcher(Rc::clone(&runtime));
4589            for request in requests {
4590                dispatcher.dispatch_and_complete(request).await;
4591            }
4592
4593            runtime.tick().await.expect("tick");
4594
4595            runtime
4596                .eval(
4597                    r#"
4598                    if (globalThis.err === null) throw new Error("Promise not rejected");
4599                    if (globalThis.err !== "io") {
4600                        throw new Error("Wrong error code: " + globalThis.err);
4601                    }
4602                "#,
4603                )
4604                .await
4605                .expect("verify error");
4606        });
4607    }
4608
4609    #[test]
4610    #[cfg(unix)]
4611    fn dispatcher_exec_hostcall_streaming_callback_delivers_chunks_and_final_result() {
4612        futures::executor::block_on(async {
4613            let runtime = Rc::new(
4614                PiJsRuntime::with_clock(DeterministicClock::new(0))
4615                    .await
4616                    .expect("runtime"),
4617            );
4618
4619            runtime
4620                .eval(
4621                    r#"
4622                    globalThis.chunks = [];
4623                    globalThis.finalResult = null;
4624                    pi.exec("sh", ["-c", "printf 'out-1\n'; printf 'err-1\n' 1>&2; printf 'out-2\n'"], {
4625                        stream: true,
4626                        onChunk: (chunk, isFinal) => {
4627                            globalThis.chunks.push({ chunk, isFinal });
4628                        },
4629                    }).then((r) => { globalThis.finalResult = r; });
4630                "#,
4631                )
4632                .await
4633                .expect("eval");
4634
4635            let requests = runtime.drain_hostcall_requests();
4636            assert_eq!(requests.len(), 1);
4637
4638            let dispatcher = build_dispatcher(Rc::clone(&runtime));
4639            for request in requests {
4640                dispatcher.dispatch_and_complete(request).await;
4641            }
4642
4643            while runtime.has_pending() {
4644                runtime.tick().await.expect("tick");
4645                runtime.drain_microtasks().await.expect("microtasks");
4646            }
4647
4648            runtime
4649                .eval(
4650                    r#"
4651                    if (!Array.isArray(globalThis.chunks) || globalThis.chunks.length < 3) {
4652                        throw new Error("Expected stream chunks, got: " + JSON.stringify(globalThis.chunks));
4653                    }
4654                    const sawStdout = globalThis.chunks.some((entry) => entry.chunk && entry.chunk.stdout && entry.chunk.stdout.includes("out-1"));
4655                    if (!sawStdout) {
4656                        throw new Error("Missing stdout chunk: " + JSON.stringify(globalThis.chunks));
4657                    }
4658                    const sawStderr = globalThis.chunks.some((entry) => entry.chunk && entry.chunk.stderr && entry.chunk.stderr.includes("err-1"));
4659                    if (!sawStderr) {
4660                        throw new Error("Missing stderr chunk: " + JSON.stringify(globalThis.chunks));
4661                    }
4662                    const finalEntry = globalThis.chunks[globalThis.chunks.length - 1];
4663                    if (!finalEntry || finalEntry.isFinal !== true) {
4664                        throw new Error("Missing final chunk marker: " + JSON.stringify(globalThis.chunks));
4665                    }
4666                    if (globalThis.finalResult === null) {
4667                        throw new Error("Promise not resolved");
4668                    }
4669                    if (globalThis.finalResult.code !== 0) {
4670                        throw new Error("Wrong exit code: " + JSON.stringify(globalThis.finalResult));
4671                    }
4672                    if (globalThis.finalResult.killed !== false) {
4673                        throw new Error("Unexpected killed flag: " + JSON.stringify(globalThis.finalResult));
4674                    }
4675                "#,
4676                )
4677                .await
4678                .expect("verify stream callback result");
4679        });
4680    }
4681
4682    #[test]
4683    #[cfg(unix)]
4684    fn dispatcher_exec_hostcall_streaming_async_iterator_delivers_chunks_in_order() {
4685        futures::executor::block_on(async {
4686            let runtime = Rc::new(
4687                PiJsRuntime::with_clock(DeterministicClock::new(0))
4688                    .await
4689                    .expect("runtime"),
4690            );
4691
4692            runtime
4693                .eval(
4694                    r#"
4695                    globalThis.iterChunks = [];
4696                    globalThis.iterDone = false;
4697                    (async () => {
4698                        const stream = pi.exec("sh", ["-c", "printf 'a\n'; printf 'b\n'"], { stream: true });
4699                        for await (const chunk of stream) {
4700                            globalThis.iterChunks.push(chunk);
4701                        }
4702                        globalThis.iterDone = true;
4703                    })();
4704                "#,
4705                )
4706                .await
4707                .expect("eval");
4708
4709            let requests = runtime.drain_hostcall_requests();
4710            assert_eq!(requests.len(), 1);
4711
4712            let dispatcher = build_dispatcher(Rc::clone(&runtime));
4713            for request in requests {
4714                dispatcher.dispatch_and_complete(request).await;
4715            }
4716
4717            while runtime.has_pending() {
4718                runtime.tick().await.expect("tick");
4719                runtime.drain_microtasks().await.expect("microtasks");
4720            }
4721
4722            runtime
4723                .eval(
4724                    r#"
4725                    if (globalThis.iterDone !== true) {
4726                        throw new Error("Async iterator did not finish");
4727                    }
4728                    if (!Array.isArray(globalThis.iterChunks) || globalThis.iterChunks.length < 2) {
4729                        throw new Error("Missing stream chunks: " + JSON.stringify(globalThis.iterChunks));
4730                    }
4731                    const stdout = globalThis.iterChunks
4732                        .map((chunk) => (chunk && typeof chunk.stdout === "string" ? chunk.stdout : ""))
4733                        .join("");
4734                    if (stdout !== "a\nb\n") {
4735                        throw new Error("Unexpected streamed stdout aggregate: " + JSON.stringify(globalThis.iterChunks));
4736                    }
4737                    const finalChunk = globalThis.iterChunks[globalThis.iterChunks.length - 1];
4738                    if (!finalChunk || finalChunk.code !== 0 || finalChunk.killed !== false) {
4739                        throw new Error("Unexpected final chunk: " + JSON.stringify(finalChunk));
4740                    }
4741                "#,
4742                )
4743                .await
4744                .expect("verify async iterator result");
4745        });
4746    }
4747
4748    #[test]
4749    #[cfg(unix)]
4750    fn dispatcher_exec_hostcall_handles_invalid_utf8() {
4751        futures::executor::block_on(async {
4752            let runtime = Rc::new(
4753                PiJsRuntime::with_clock(DeterministicClock::new(0))
4754                    .await
4755                    .expect("runtime"),
4756            );
4757
4758            // Output 'a', then invalid 0xFF, then 'b'.
4759            // Expected: 'a' in one chunk (or part of chunk), then replacement char, then 'b'.
4760            // Note: printf '\xff' might vary by shell, but \377 should work.
4761            runtime
4762                .eval(
4763                    r#"
4764                    globalThis.output = "";
4765                    globalThis.outputDone = false;
4766                    (async () => {
4767                        const stream = pi.exec("sh", ["-c", "printf 'a\\377b'"], { stream: true });
4768                        for await (const chunk of stream) {
4769                            if (chunk.stdout) globalThis.output += chunk.stdout;
4770                        }
4771                        globalThis.outputDone = true;
4772                    })();
4773                "#,
4774                )
4775                .await
4776                .expect("eval");
4777
4778            let requests = runtime.drain_hostcall_requests();
4779            assert_eq!(requests.len(), 1);
4780
4781            let dispatcher = build_dispatcher(Rc::clone(&runtime));
4782            for request in requests {
4783                dispatcher.dispatch_and_complete(request).await;
4784            }
4785
4786            while runtime.has_pending() {
4787                runtime.tick().await.expect("tick");
4788                runtime.drain_microtasks().await.expect("microtasks");
4789            }
4790
4791            runtime
4792                .eval(
4793                    r#"
4794                    if (globalThis.outputDone !== true) {
4795                        throw new Error("Streaming output collection did not finish");
4796                    }
4797                    // \uFFFD is the replacement character
4798                    if (globalThis.output !== "a\uFFFDb") {
4799                        throw new Error("Expected 'a\\uFFFDb', got: " + globalThis.output + " (len " + globalThis.output.length + ")");
4800                    }
4801                "#,
4802                )
4803                .await
4804                .expect("verify invalid utf8 handling");
4805        });
4806    }
4807
4808    #[test]
4809    #[cfg(unix)]
4810    #[ignore = "flaky on CI: timing-sensitive 500ms exec timeout with futures::executor"]
4811    fn dispatcher_exec_hostcall_streaming_timeout_marks_final_chunk_killed() {
4812        futures::executor::block_on(async {
4813            let runtime = Rc::new(
4814                PiJsRuntime::with_clock(DeterministicClock::new(0))
4815                    .await
4816                    .expect("runtime"),
4817            );
4818
4819            runtime
4820                .eval(
4821                    r#"
4822                    globalThis.timeoutChunks = [];
4823                    globalThis.timeoutResult = null;
4824                    globalThis.timeoutError = null;
4825                    pi.exec("sh", ["-c", "printf 'start\n'; sleep 5; printf 'late\n'"], {
4826                        stream: true,
4827                        timeoutMs: 500,
4828                        onChunk: (chunk, isFinal) => {
4829                            globalThis.timeoutChunks.push({ chunk, isFinal });
4830                        },
4831                    })
4832                        .then((r) => { globalThis.timeoutResult = r; })
4833                        .catch((e) => { globalThis.timeoutError = e; });
4834                "#,
4835                )
4836                .await
4837                .expect("eval");
4838
4839            let requests = runtime.drain_hostcall_requests();
4840            assert_eq!(requests.len(), 1);
4841
4842            let dispatcher = build_dispatcher(Rc::clone(&runtime));
4843            for request in requests {
4844                dispatcher.dispatch_and_complete(request).await;
4845            }
4846
4847            while runtime.has_pending() {
4848                runtime.tick().await.expect("tick");
4849                runtime.drain_microtasks().await.expect("microtasks");
4850            }
4851
4852            runtime
4853                .eval(
4854                    r#"
4855                    if (globalThis.timeoutError !== null) {
4856                        throw new Error("Unexpected timeout error: " + JSON.stringify(globalThis.timeoutError));
4857                    }
4858                    if (globalThis.timeoutResult === null) {
4859                        throw new Error("Timeout stream promise not resolved");
4860                    }
4861                    if (globalThis.timeoutResult.killed !== true) {
4862                        throw new Error("Expected killed=true for timeout stream: " + JSON.stringify(globalThis.timeoutResult));
4863                    }
4864                    const finalEntry = globalThis.timeoutChunks[globalThis.timeoutChunks.length - 1];
4865                    if (!finalEntry || finalEntry.isFinal !== true) {
4866                        throw new Error("Missing final timeout chunk marker: " + JSON.stringify(globalThis.timeoutChunks));
4867                    }
4868                    const sawLateOutput = globalThis.timeoutChunks.some((entry) =>
4869                        entry.chunk && entry.chunk.stdout && entry.chunk.stdout.includes("late")
4870                    );
4871                    if (sawLateOutput) {
4872                        throw new Error("Process output after timeout kill: " + JSON.stringify(globalThis.timeoutChunks));
4873                    }
4874                "#,
4875                )
4876                .await
4877                .expect("verify timeout stream result");
4878        });
4879    }
4880
4881    #[test]
4882    fn dispatcher_http_hostcall_executes_and_resolves_promise() {
4883        futures::executor::block_on(async {
4884            let addr = spawn_http_server("hello");
4885            let url = format!("http://{addr}/test");
4886
4887            let runtime = Rc::new(
4888                PiJsRuntime::with_clock(DeterministicClock::new(0))
4889                    .await
4890                    .expect("runtime"),
4891            );
4892
4893            let script = format!(
4894                r#"
4895                globalThis.result = null;
4896                pi.http({{ url: "{url}", method: "GET" }})
4897                    .then((r) => {{ globalThis.result = r; }});
4898            "#
4899            );
4900            runtime.eval(&script).await.expect("eval");
4901
4902            let requests = runtime.drain_hostcall_requests();
4903            assert_eq!(requests.len(), 1);
4904
4905            let http_connector = HttpConnector::new(HttpConnectorConfig {
4906                require_tls: false,
4907                ..Default::default()
4908            });
4909            let dispatcher = ExtensionDispatcher::new(
4910                Rc::clone(&runtime),
4911                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
4912                Arc::new(http_connector),
4913                Arc::new(NullSession),
4914                Arc::new(NullUiHandler),
4915                PathBuf::from("."),
4916            );
4917
4918            for request in requests {
4919                dispatcher.dispatch_and_complete(request).await;
4920            }
4921
4922            runtime.tick().await.expect("tick");
4923
4924            runtime
4925                .eval(
4926                    r#"
4927                    if (globalThis.result === null) throw new Error("Promise not resolved");
4928                    if (globalThis.result.status !== 200) {
4929                        throw new Error("Wrong status: " + globalThis.result.status);
4930                    }
4931                    if (globalThis.result.body !== "hello") {
4932                        throw new Error("Wrong body: " + globalThis.result.body);
4933                    }
4934                "#,
4935                )
4936                .await
4937                .expect("verify result");
4938        });
4939    }
4940
4941    #[test]
4942    fn dispatcher_http_hostcall_invalid_method_rejects_promise() {
4943        futures::executor::block_on(async {
4944            let runtime = Rc::new(
4945                PiJsRuntime::with_clock(DeterministicClock::new(0))
4946                    .await
4947                    .expect("runtime"),
4948            );
4949
4950            runtime
4951                .eval(
4952                    r#"
4953                    globalThis.err = null;
4954                    pi.http({ url: "https://example.com", method: "PUT" })
4955                        .catch((e) => { globalThis.err = e.code; });
4956                "#,
4957                )
4958                .await
4959                .expect("eval");
4960
4961            let requests = runtime.drain_hostcall_requests();
4962            assert_eq!(requests.len(), 1);
4963
4964            let http_connector = HttpConnector::new(HttpConnectorConfig {
4965                require_tls: false,
4966                ..Default::default()
4967            });
4968            let dispatcher = ExtensionDispatcher::new(
4969                Rc::clone(&runtime),
4970                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
4971                Arc::new(http_connector),
4972                Arc::new(NullSession),
4973                Arc::new(NullUiHandler),
4974                PathBuf::from("."),
4975            );
4976
4977            for request in requests {
4978                dispatcher.dispatch_and_complete(request).await;
4979            }
4980
4981            runtime.tick().await.expect("tick");
4982
4983            runtime
4984                .eval(
4985                    r#"
4986                    if (globalThis.err === null) throw new Error("Promise not rejected");
4987                    if (globalThis.err !== "invalid_request") {
4988                        throw new Error("Wrong error code: " + globalThis.err);
4989                    }
4990                "#,
4991                )
4992                .await
4993                .expect("verify error");
4994        });
4995    }
4996
4997    #[test]
4998    fn dispatcher_ui_hostcall_executes_and_resolves_promise() {
4999        futures::executor::block_on(async {
5000            let runtime = Rc::new(
5001                PiJsRuntime::with_clock(DeterministicClock::new(0))
5002                    .await
5003                    .expect("runtime"),
5004            );
5005
5006            runtime
5007                .eval(
5008                    r#"
5009                    globalThis.uiResult = null;
5010                    pi.ui("confirm", { title: "Confirm?" }).then((r) => { globalThis.uiResult = r; });
5011                "#,
5012                )
5013                .await
5014                .expect("eval");
5015
5016            let requests = runtime.drain_hostcall_requests();
5017            assert_eq!(requests.len(), 1);
5018
5019            let captured = Arc::new(Mutex::new(Vec::new()));
5020            let dispatcher = ExtensionDispatcher::new(
5021                Rc::clone(&runtime),
5022                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
5023                Arc::new(HttpConnector::with_defaults()),
5024                Arc::new(NullSession),
5025                Arc::new(TestUiHandler {
5026                    captured: Arc::clone(&captured),
5027                    response_value: serde_json::json!({ "ok": true }),
5028                }),
5029                PathBuf::from("."),
5030            );
5031
5032            for request in requests {
5033                dispatcher.dispatch_and_complete(request).await;
5034            }
5035
5036            runtime.tick().await.expect("tick");
5037
5038            runtime
5039                .eval(
5040                    r#"
5041                    if (!globalThis.uiResult || globalThis.uiResult.ok !== true) {
5042                        throw new Error("Wrong UI result: " + JSON.stringify(globalThis.uiResult));
5043                    }
5044                "#,
5045                )
5046                .await
5047                .expect("verify result");
5048
5049            let seen = captured.lock().unwrap().clone();
5050            assert_eq!(seen.len(), 1);
5051            assert_eq!(seen[0].method, "confirm");
5052        });
5053    }
5054
5055    #[test]
5056    fn dispatcher_extension_ui_set_status_includes_text_field() {
5057        futures::executor::block_on(async {
5058            let runtime = Rc::new(
5059                PiJsRuntime::with_clock(DeterministicClock::new(0))
5060                    .await
5061                    .expect("runtime"),
5062            );
5063
5064            runtime
5065                .eval(
5066                    r#"
5067                    const ui = __pi_make_extension_ui(true);
5068                    ui.setStatus("key", "hello");
5069                "#,
5070                )
5071                .await
5072                .expect("eval");
5073
5074            let requests = runtime.drain_hostcall_requests();
5075            assert_eq!(requests.len(), 1);
5076
5077            let captured = Arc::new(Mutex::new(Vec::new()));
5078            let dispatcher = ExtensionDispatcher::new(
5079                Rc::clone(&runtime),
5080                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
5081                Arc::new(HttpConnector::with_defaults()),
5082                Arc::new(NullSession),
5083                Arc::new(TestUiHandler {
5084                    captured: Arc::clone(&captured),
5085                    response_value: Value::Null,
5086                }),
5087                PathBuf::from("."),
5088            );
5089
5090            for request in requests {
5091                dispatcher.dispatch_and_complete(request).await;
5092            }
5093
5094            runtime.tick().await.expect("tick");
5095
5096            let seen = captured.lock().unwrap().clone();
5097            assert_eq!(seen.len(), 1);
5098            assert_eq!(seen[0].method, "setStatus");
5099            assert_eq!(
5100                seen[0].payload.get("statusKey").and_then(Value::as_str),
5101                Some("key")
5102            );
5103            assert_eq!(
5104                seen[0].payload.get("statusText").and_then(Value::as_str),
5105                Some("hello")
5106            );
5107            assert_eq!(
5108                seen[0].payload.get("text").and_then(Value::as_str),
5109                Some("hello")
5110            );
5111        });
5112    }
5113
5114    #[test]
5115    fn dispatcher_extension_ui_set_widget_includes_widget_lines_and_content() {
5116        futures::executor::block_on(async {
5117            let runtime = Rc::new(
5118                PiJsRuntime::with_clock(DeterministicClock::new(0))
5119                    .await
5120                    .expect("runtime"),
5121            );
5122
5123            runtime
5124                .eval(
5125                    r#"
5126                    const ui = __pi_make_extension_ui(true);
5127                    ui.setWidget("widget", ["a", "b"]);
5128                "#,
5129                )
5130                .await
5131                .expect("eval");
5132
5133            let requests = runtime.drain_hostcall_requests();
5134            assert_eq!(requests.len(), 1);
5135
5136            let captured = Arc::new(Mutex::new(Vec::new()));
5137            let dispatcher = ExtensionDispatcher::new(
5138                Rc::clone(&runtime),
5139                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
5140                Arc::new(HttpConnector::with_defaults()),
5141                Arc::new(NullSession),
5142                Arc::new(TestUiHandler {
5143                    captured: Arc::clone(&captured),
5144                    response_value: Value::Null,
5145                }),
5146                PathBuf::from("."),
5147            );
5148
5149            for request in requests {
5150                dispatcher.dispatch_and_complete(request).await;
5151            }
5152
5153            runtime.tick().await.expect("tick");
5154
5155            let seen = captured.lock().unwrap().clone();
5156            assert_eq!(seen.len(), 1);
5157            assert_eq!(seen[0].method, "setWidget");
5158            assert_eq!(
5159                seen[0].payload.get("widgetKey").and_then(Value::as_str),
5160                Some("widget")
5161            );
5162            assert_eq!(
5163                seen[0].payload.get("content").and_then(Value::as_str),
5164                Some("a\nb")
5165            );
5166            assert_eq!(
5167                seen[0].payload.get("widgetLines").and_then(Value::as_array),
5168                seen[0].payload.get("lines").and_then(Value::as_array)
5169            );
5170        });
5171    }
5172
5173    #[test]
5174    fn dispatcher_events_hostcall_rejects_promise() {
5175        futures::executor::block_on(async {
5176            let runtime = Rc::new(
5177                PiJsRuntime::with_clock(DeterministicClock::new(0))
5178                    .await
5179                    .expect("runtime"),
5180            );
5181
5182            runtime
5183                .eval(
5184                    r#"
5185                    globalThis.err = null;
5186                    pi.events("setActiveTools", { tools: ["read"] })
5187                        .catch((e) => { globalThis.err = e.code; });
5188                "#,
5189                )
5190                .await
5191                .expect("eval");
5192
5193            let requests = runtime.drain_hostcall_requests();
5194            assert_eq!(requests.len(), 1);
5195
5196            let dispatcher = build_dispatcher(Rc::clone(&runtime));
5197            for request in requests {
5198                dispatcher.dispatch_and_complete(request).await;
5199            }
5200
5201            runtime.tick().await.expect("tick");
5202
5203            runtime
5204                .eval(
5205                    r#"
5206                    if (globalThis.err === null) throw new Error("Promise not rejected");
5207                    if (globalThis.err !== "invalid_request") {
5208                        throw new Error("Wrong error code: " + globalThis.err);
5209                    }
5210                "#,
5211                )
5212                .await
5213                .expect("verify error");
5214        });
5215    }
5216
5217    #[test]
5218    fn dispatcher_events_list_returns_registered_hooks() {
5219        futures::executor::block_on(async {
5220            let runtime = Rc::new(
5221                PiJsRuntime::with_clock(DeterministicClock::new(0))
5222                    .await
5223                    .expect("runtime"),
5224            );
5225
5226            runtime
5227                .eval(
5228                    r#"
5229                    globalThis.eventsList = null;
5230                    __pi_begin_extension("ext.a", { name: "ext.a" });
5231                    pi.on("custom_event", (_payload, _ctx) => {});
5232                    pi.events("list", {}).then((r) => { globalThis.eventsList = r; });
5233                    __pi_end_extension();
5234                "#,
5235                )
5236                .await
5237                .expect("eval");
5238
5239            let requests = runtime.drain_hostcall_requests();
5240            assert_eq!(requests.len(), 1);
5241
5242            let dispatcher = build_dispatcher(Rc::clone(&runtime));
5243            for request in requests {
5244                dispatcher.dispatch_and_complete(request).await;
5245            }
5246
5247            runtime.tick().await.expect("tick");
5248
5249            runtime
5250                .eval(
5251                    r#"
5252                    if (!globalThis.eventsList) throw new Error("Promise not resolved");
5253                    const events = globalThis.eventsList.events;
5254                    if (!Array.isArray(events)) throw new Error("Missing events array");
5255                    if (events.length !== 1 || events[0] !== "custom_event") {
5256                        throw new Error("Wrong events list: " + JSON.stringify(events));
5257                    }
5258                "#,
5259                )
5260                .await
5261                .expect("verify list");
5262        });
5263    }
5264
5265    #[test]
5266    fn dispatcher_session_set_model_resolves_and_persists() {
5267        futures::executor::block_on(async {
5268            let runtime = Rc::new(
5269                PiJsRuntime::with_clock(DeterministicClock::new(0))
5270                    .await
5271                    .expect("runtime"),
5272            );
5273
5274            runtime
5275                .eval(
5276                    r#"
5277                    globalThis.setResult = null;
5278                    pi.session("set_model", { provider: "anthropic", modelId: "claude-sonnet-4-20250514" })
5279                        .then((r) => { globalThis.setResult = r; });
5280                "#,
5281                )
5282                .await
5283                .expect("eval");
5284
5285            let requests = runtime.drain_hostcall_requests();
5286            assert_eq!(requests.len(), 1);
5287
5288            let state = Arc::new(Mutex::new(serde_json::json!({})));
5289            let session = Arc::new(TestSession {
5290                state: Arc::clone(&state),
5291                messages: Arc::new(Mutex::new(Vec::new())),
5292                entries: Arc::new(Mutex::new(Vec::new())),
5293                branch: Arc::new(Mutex::new(Vec::new())),
5294                name: Arc::new(Mutex::new(None)),
5295                custom_entries: Arc::new(Mutex::new(Vec::new())),
5296                labels: Arc::new(Mutex::new(Vec::new())),
5297            });
5298
5299            let dispatcher = ExtensionDispatcher::new(
5300                Rc::clone(&runtime),
5301                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
5302                Arc::new(HttpConnector::with_defaults()),
5303                session,
5304                Arc::new(NullUiHandler),
5305                PathBuf::from("."),
5306            );
5307
5308            for request in requests {
5309                dispatcher.dispatch_and_complete(request).await;
5310            }
5311
5312            while runtime.has_pending() {
5313                runtime.tick().await.expect("tick");
5314                runtime.drain_microtasks().await.expect("microtasks");
5315            }
5316
5317            runtime
5318                .eval(
5319                    r#"
5320                    if (globalThis.setResult !== true) {
5321                        throw new Error("set_model should resolve to true, got: " + JSON.stringify(globalThis.setResult));
5322                    }
5323                "#,
5324                )
5325                .await
5326                .expect("verify set_model result");
5327
5328            let final_state = state.lock().unwrap().clone();
5329            assert_eq!(
5330                final_state.get("provider").and_then(Value::as_str),
5331                Some("anthropic")
5332            );
5333            assert_eq!(
5334                final_state.get("modelId").and_then(Value::as_str),
5335                Some("claude-sonnet-4-20250514")
5336            );
5337        });
5338    }
5339
5340    #[test]
5341    fn dispatcher_session_get_model_resolves_provider_and_model_id() {
5342        futures::executor::block_on(async {
5343            let runtime = Rc::new(
5344                PiJsRuntime::with_clock(DeterministicClock::new(0))
5345                    .await
5346                    .expect("runtime"),
5347            );
5348
5349            runtime
5350                .eval(
5351                    r#"
5352                    globalThis.model = null;
5353                    pi.session("get_model", {}).then((r) => { globalThis.model = r; });
5354                "#,
5355                )
5356                .await
5357                .expect("eval");
5358
5359            let requests = runtime.drain_hostcall_requests();
5360            assert_eq!(requests.len(), 1);
5361
5362            let state = Arc::new(Mutex::new(serde_json::json!({
5363                "provider": "openai",
5364                "modelId": "gpt-4o",
5365            })));
5366            let session = Arc::new(TestSession {
5367                state: Arc::clone(&state),
5368                messages: Arc::new(Mutex::new(Vec::new())),
5369                entries: Arc::new(Mutex::new(Vec::new())),
5370                branch: Arc::new(Mutex::new(Vec::new())),
5371                name: Arc::new(Mutex::new(None)),
5372                custom_entries: Arc::new(Mutex::new(Vec::new())),
5373                labels: Arc::new(Mutex::new(Vec::new())),
5374            });
5375
5376            let dispatcher = ExtensionDispatcher::new(
5377                Rc::clone(&runtime),
5378                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
5379                Arc::new(HttpConnector::with_defaults()),
5380                session,
5381                Arc::new(NullUiHandler),
5382                PathBuf::from("."),
5383            );
5384
5385            for request in requests {
5386                dispatcher.dispatch_and_complete(request).await;
5387            }
5388
5389            while runtime.has_pending() {
5390                runtime.tick().await.expect("tick");
5391                runtime.drain_microtasks().await.expect("microtasks");
5392            }
5393
5394            runtime
5395                .eval(
5396                    r#"
5397                    if (!globalThis.model) throw new Error("get_model not resolved");
5398                    if (globalThis.model.provider !== "openai") {
5399                        throw new Error("Wrong provider: " + globalThis.model.provider);
5400                    }
5401                    if (globalThis.model.modelId !== "gpt-4o") {
5402                        throw new Error("Wrong modelId: " + globalThis.model.modelId);
5403                    }
5404                "#,
5405                )
5406                .await
5407                .expect("verify get_model result");
5408        });
5409    }
5410
5411    #[test]
5412    fn dispatcher_session_set_model_missing_fields_rejects() {
5413        futures::executor::block_on(async {
5414            let runtime = Rc::new(
5415                PiJsRuntime::with_clock(DeterministicClock::new(0))
5416                    .await
5417                    .expect("runtime"),
5418            );
5419
5420            runtime
5421                .eval(
5422                    r#"
5423                    globalThis.errNoProvider = null;
5424                    globalThis.errNoModelId = null;
5425                    globalThis.errEmpty = null;
5426                    pi.session("set_model", { modelId: "claude-sonnet-4-20250514" })
5427                        .catch((e) => { globalThis.errNoProvider = e.code; });
5428                    pi.session("set_model", { provider: "anthropic" })
5429                        .catch((e) => { globalThis.errNoModelId = e.code; });
5430                    pi.session("set_model", {})
5431                        .catch((e) => { globalThis.errEmpty = e.code; });
5432                "#,
5433                )
5434                .await
5435                .expect("eval");
5436
5437            let requests = runtime.drain_hostcall_requests();
5438            assert_eq!(requests.len(), 3);
5439
5440            let dispatcher = build_dispatcher(Rc::clone(&runtime));
5441            for request in requests {
5442                dispatcher.dispatch_and_complete(request).await;
5443            }
5444
5445            while runtime.has_pending() {
5446                runtime.tick().await.expect("tick");
5447                runtime.drain_microtasks().await.expect("microtasks");
5448            }
5449
5450            runtime
5451                .eval(
5452                    r#"
5453                    if (globalThis.errNoProvider !== "invalid_request") {
5454                        throw new Error("Missing provider should reject: " + globalThis.errNoProvider);
5455                    }
5456                    if (globalThis.errNoModelId !== "invalid_request") {
5457                        throw new Error("Missing modelId should reject: " + globalThis.errNoModelId);
5458                    }
5459                    if (globalThis.errEmpty !== "invalid_request") {
5460                        throw new Error("Empty payload should reject: " + globalThis.errEmpty);
5461                    }
5462                "#,
5463                )
5464                .await
5465                .expect("verify validation errors");
5466        });
5467    }
5468
5469    #[test]
5470    fn dispatcher_session_set_then_get_model_round_trip() {
5471        futures::executor::block_on(async {
5472            let runtime = Rc::new(
5473                PiJsRuntime::with_clock(DeterministicClock::new(0))
5474                    .await
5475                    .expect("runtime"),
5476            );
5477
5478            // Phase 1: set_model
5479            runtime
5480                .eval(
5481                    r#"
5482                    globalThis.setDone = false;
5483                    pi.session("set_model", { provider: "gemini", modelId: "gemini-2.0-flash" })
5484                        .then(() => { globalThis.setDone = true; });
5485                "#,
5486                )
5487                .await
5488                .expect("eval set");
5489
5490            let requests = runtime.drain_hostcall_requests();
5491            assert_eq!(requests.len(), 1);
5492
5493            let state = Arc::new(Mutex::new(serde_json::json!({})));
5494            let session = Arc::new(TestSession {
5495                state: Arc::clone(&state),
5496                messages: Arc::new(Mutex::new(Vec::new())),
5497                entries: Arc::new(Mutex::new(Vec::new())),
5498                branch: Arc::new(Mutex::new(Vec::new())),
5499                name: Arc::new(Mutex::new(None)),
5500                custom_entries: Arc::new(Mutex::new(Vec::new())),
5501                labels: Arc::new(Mutex::new(Vec::new())),
5502            });
5503
5504            let dispatcher = ExtensionDispatcher::new(
5505                Rc::clone(&runtime),
5506                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
5507                Arc::new(HttpConnector::with_defaults()),
5508                session as Arc<dyn ExtensionSession + Send + Sync>,
5509                Arc::new(NullUiHandler),
5510                PathBuf::from("."),
5511            );
5512
5513            for request in requests {
5514                dispatcher.dispatch_and_complete(request).await;
5515            }
5516
5517            while runtime.has_pending() {
5518                runtime.tick().await.expect("tick");
5519                runtime.drain_microtasks().await.expect("microtasks");
5520            }
5521
5522            // Phase 2: get_model
5523            runtime
5524                .eval(
5525                    r#"
5526                    globalThis.model = null;
5527                    pi.session("get_model", {}).then((r) => { globalThis.model = r; });
5528                "#,
5529                )
5530                .await
5531                .expect("eval get");
5532
5533            let requests = runtime.drain_hostcall_requests();
5534            assert_eq!(requests.len(), 1);
5535
5536            for request in requests {
5537                dispatcher.dispatch_and_complete(request).await;
5538            }
5539
5540            while runtime.has_pending() {
5541                runtime.tick().await.expect("tick");
5542                runtime.drain_microtasks().await.expect("microtasks");
5543            }
5544
5545            runtime
5546                .eval(
5547                    r#"
5548                    if (!globalThis.model) throw new Error("get_model not resolved");
5549                    if (globalThis.model.provider !== "gemini") {
5550                        throw new Error("Wrong provider: " + globalThis.model.provider);
5551                    }
5552                    if (globalThis.model.modelId !== "gemini-2.0-flash") {
5553                        throw new Error("Wrong modelId: " + globalThis.model.modelId);
5554                    }
5555                "#,
5556                )
5557                .await
5558                .expect("verify round trip");
5559        });
5560    }
5561
5562    #[test]
5563    fn dispatcher_session_set_thinking_level_resolves() {
5564        futures::executor::block_on(async {
5565            let runtime = Rc::new(
5566                PiJsRuntime::with_clock(DeterministicClock::new(0))
5567                    .await
5568                    .expect("runtime"),
5569            );
5570
5571            runtime
5572                .eval(
5573                    r#"
5574                    globalThis.setDone = false;
5575                    pi.session("set_thinking_level", { level: "high" })
5576                        .then(() => { globalThis.setDone = true; });
5577                "#,
5578                )
5579                .await
5580                .expect("eval");
5581
5582            let requests = runtime.drain_hostcall_requests();
5583            assert_eq!(requests.len(), 1);
5584
5585            let state = Arc::new(Mutex::new(serde_json::json!({})));
5586            let session = Arc::new(TestSession {
5587                state: Arc::clone(&state),
5588                messages: Arc::new(Mutex::new(Vec::new())),
5589                entries: Arc::new(Mutex::new(Vec::new())),
5590                branch: Arc::new(Mutex::new(Vec::new())),
5591                name: Arc::new(Mutex::new(None)),
5592                custom_entries: Arc::new(Mutex::new(Vec::new())),
5593                labels: Arc::new(Mutex::new(Vec::new())),
5594            });
5595
5596            let dispatcher = ExtensionDispatcher::new(
5597                Rc::clone(&runtime),
5598                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
5599                Arc::new(HttpConnector::with_defaults()),
5600                session,
5601                Arc::new(NullUiHandler),
5602                PathBuf::from("."),
5603            );
5604
5605            for request in requests {
5606                dispatcher.dispatch_and_complete(request).await;
5607            }
5608
5609            while runtime.has_pending() {
5610                runtime.tick().await.expect("tick");
5611                runtime.drain_microtasks().await.expect("microtasks");
5612            }
5613
5614            // set_thinking_level resolves to null (not true like set_model)
5615            runtime
5616                .eval(
5617                    r#"
5618                    if (globalThis.setDone !== true) {
5619                        throw new Error("set_thinking_level not resolved");
5620                    }
5621                "#,
5622                )
5623                .await
5624                .expect("verify set_thinking_level");
5625
5626            let final_state = state.lock().unwrap().clone();
5627            assert_eq!(
5628                final_state.get("thinkingLevel").and_then(Value::as_str),
5629                Some("high")
5630            );
5631        });
5632    }
5633
5634    #[test]
5635    fn dispatcher_session_get_thinking_level_resolves() {
5636        futures::executor::block_on(async {
5637            let runtime = Rc::new(
5638                PiJsRuntime::with_clock(DeterministicClock::new(0))
5639                    .await
5640                    .expect("runtime"),
5641            );
5642
5643            runtime
5644                .eval(
5645                    r#"
5646                    globalThis.level = "__unset__";
5647                    pi.session("get_thinking_level", {}).then((r) => { globalThis.level = r; });
5648                "#,
5649                )
5650                .await
5651                .expect("eval");
5652
5653            let requests = runtime.drain_hostcall_requests();
5654            assert_eq!(requests.len(), 1);
5655
5656            let state = Arc::new(Mutex::new(serde_json::json!({
5657                "thinkingLevel": "medium",
5658            })));
5659            let session = Arc::new(TestSession {
5660                state: Arc::clone(&state),
5661                messages: Arc::new(Mutex::new(Vec::new())),
5662                entries: Arc::new(Mutex::new(Vec::new())),
5663                branch: Arc::new(Mutex::new(Vec::new())),
5664                name: Arc::new(Mutex::new(None)),
5665                custom_entries: Arc::new(Mutex::new(Vec::new())),
5666                labels: Arc::new(Mutex::new(Vec::new())),
5667            });
5668
5669            let dispatcher = ExtensionDispatcher::new(
5670                Rc::clone(&runtime),
5671                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
5672                Arc::new(HttpConnector::with_defaults()),
5673                session,
5674                Arc::new(NullUiHandler),
5675                PathBuf::from("."),
5676            );
5677
5678            for request in requests {
5679                dispatcher.dispatch_and_complete(request).await;
5680            }
5681
5682            while runtime.has_pending() {
5683                runtime.tick().await.expect("tick");
5684                runtime.drain_microtasks().await.expect("microtasks");
5685            }
5686
5687            runtime
5688                .eval(
5689                    r#"
5690                    if (globalThis.level !== "medium") {
5691                        throw new Error("Wrong thinking level: " + JSON.stringify(globalThis.level));
5692                    }
5693                "#,
5694                )
5695                .await
5696                .expect("verify get_thinking_level");
5697        });
5698    }
5699
5700    #[test]
5701    fn dispatcher_session_get_thinking_level_null_when_unset() {
5702        futures::executor::block_on(async {
5703            let runtime = Rc::new(
5704                PiJsRuntime::with_clock(DeterministicClock::new(0))
5705                    .await
5706                    .expect("runtime"),
5707            );
5708
5709            runtime
5710                .eval(
5711                    r#"
5712                    globalThis.level = "__unset__";
5713                    pi.session("get_thinking_level", {}).then((r) => { globalThis.level = r; });
5714                "#,
5715                )
5716                .await
5717                .expect("eval");
5718
5719            let requests = runtime.drain_hostcall_requests();
5720            assert_eq!(requests.len(), 1);
5721
5722            let dispatcher = build_dispatcher(Rc::clone(&runtime));
5723            for request in requests {
5724                dispatcher.dispatch_and_complete(request).await;
5725            }
5726
5727            while runtime.has_pending() {
5728                runtime.tick().await.expect("tick");
5729                runtime.drain_microtasks().await.expect("microtasks");
5730            }
5731
5732            runtime
5733                .eval(
5734                    r#"
5735                    if (globalThis.level !== null) {
5736                        throw new Error("Unset thinking level should be null, got: " + JSON.stringify(globalThis.level));
5737                    }
5738                "#,
5739                )
5740                .await
5741                .expect("verify null thinking level");
5742        });
5743    }
5744
5745    #[test]
5746    fn dispatcher_session_set_thinking_level_missing_level_rejects() {
5747        futures::executor::block_on(async {
5748            let runtime = Rc::new(
5749                PiJsRuntime::with_clock(DeterministicClock::new(0))
5750                    .await
5751                    .expect("runtime"),
5752            );
5753
5754            runtime
5755                .eval(
5756                    r#"
5757                    globalThis.err = null;
5758                    pi.session("set_thinking_level", {})
5759                        .catch((e) => { globalThis.err = e.code; });
5760                "#,
5761                )
5762                .await
5763                .expect("eval");
5764
5765            let requests = runtime.drain_hostcall_requests();
5766            assert_eq!(requests.len(), 1);
5767
5768            let dispatcher = build_dispatcher(Rc::clone(&runtime));
5769            for request in requests {
5770                dispatcher.dispatch_and_complete(request).await;
5771            }
5772
5773            while runtime.has_pending() {
5774                runtime.tick().await.expect("tick");
5775                runtime.drain_microtasks().await.expect("microtasks");
5776            }
5777
5778            runtime
5779                .eval(
5780                    r#"
5781                    if (globalThis.err !== "invalid_request") {
5782                        throw new Error("Missing level should reject: " + globalThis.err);
5783                    }
5784                "#,
5785                )
5786                .await
5787                .expect("verify validation error");
5788        });
5789    }
5790
5791    #[test]
5792    fn dispatcher_session_set_then_get_thinking_level_round_trip() {
5793        futures::executor::block_on(async {
5794            let runtime = Rc::new(
5795                PiJsRuntime::with_clock(DeterministicClock::new(0))
5796                    .await
5797                    .expect("runtime"),
5798            );
5799
5800            // Phase 1: set
5801            runtime
5802                .eval(
5803                    r#"
5804                    globalThis.setDone = false;
5805                    pi.session("set_thinking_level", { level: "low" })
5806                        .then(() => { globalThis.setDone = true; });
5807                "#,
5808                )
5809                .await
5810                .expect("eval set");
5811
5812            let requests = runtime.drain_hostcall_requests();
5813            assert_eq!(requests.len(), 1);
5814
5815            let state = Arc::new(Mutex::new(serde_json::json!({})));
5816            let session = Arc::new(TestSession {
5817                state: Arc::clone(&state),
5818                messages: Arc::new(Mutex::new(Vec::new())),
5819                entries: Arc::new(Mutex::new(Vec::new())),
5820                branch: Arc::new(Mutex::new(Vec::new())),
5821                name: Arc::new(Mutex::new(None)),
5822                custom_entries: Arc::new(Mutex::new(Vec::new())),
5823                labels: Arc::new(Mutex::new(Vec::new())),
5824            });
5825
5826            let dispatcher = ExtensionDispatcher::new(
5827                Rc::clone(&runtime),
5828                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
5829                Arc::new(HttpConnector::with_defaults()),
5830                session as Arc<dyn ExtensionSession + Send + Sync>,
5831                Arc::new(NullUiHandler),
5832                PathBuf::from("."),
5833            );
5834
5835            for request in requests {
5836                dispatcher.dispatch_and_complete(request).await;
5837            }
5838
5839            while runtime.has_pending() {
5840                runtime.tick().await.expect("tick");
5841                runtime.drain_microtasks().await.expect("microtasks");
5842            }
5843
5844            // Phase 2: get
5845            runtime
5846                .eval(
5847                    r#"
5848                    globalThis.level = "__unset__";
5849                    pi.session("get_thinking_level", {}).then((r) => { globalThis.level = r; });
5850                "#,
5851                )
5852                .await
5853                .expect("eval get");
5854
5855            let requests = runtime.drain_hostcall_requests();
5856            assert_eq!(requests.len(), 1);
5857
5858            for request in requests {
5859                dispatcher.dispatch_and_complete(request).await;
5860            }
5861
5862            while runtime.has_pending() {
5863                runtime.tick().await.expect("tick");
5864                runtime.drain_microtasks().await.expect("microtasks");
5865            }
5866
5867            runtime
5868                .eval(
5869                    r#"
5870                    if (globalThis.level !== "low") {
5871                        throw new Error("Round trip failed, got: " + JSON.stringify(globalThis.level));
5872                    }
5873                "#,
5874                )
5875                .await
5876                .expect("verify round trip");
5877        });
5878    }
5879
5880    #[test]
5881    fn dispatcher_session_model_ops_accept_camel_case_aliases() {
5882        futures::executor::block_on(async {
5883            let runtime = Rc::new(
5884                PiJsRuntime::with_clock(DeterministicClock::new(0))
5885                    .await
5886                    .expect("runtime"),
5887            );
5888
5889            runtime
5890                .eval(
5891                    r#"
5892                    globalThis.setDone = false;
5893                    globalThis.model = null;
5894                    globalThis.thinkingSet = false;
5895                    globalThis.thinking = "__unset__";
5896                    pi.session("setmodel", { provider: "azure", modelId: "gpt-4" })
5897                        .then(() => { globalThis.setDone = true; });
5898                    pi.session("getmodel", {}).then((r) => { globalThis.model = r; });
5899                    pi.session("setthinkinglevel", { level: "high" })
5900                        .then(() => { globalThis.thinkingSet = true; });
5901                    pi.session("getthinkinglevel", {}).then((r) => { globalThis.thinking = r; });
5902                "#,
5903                )
5904                .await
5905                .expect("eval");
5906
5907            let requests = runtime.drain_hostcall_requests();
5908            assert_eq!(requests.len(), 4);
5909
5910            let state = Arc::new(Mutex::new(serde_json::json!({})));
5911            let session = Arc::new(TestSession {
5912                state: Arc::clone(&state),
5913                messages: Arc::new(Mutex::new(Vec::new())),
5914                entries: Arc::new(Mutex::new(Vec::new())),
5915                branch: Arc::new(Mutex::new(Vec::new())),
5916                name: Arc::new(Mutex::new(None)),
5917                custom_entries: Arc::new(Mutex::new(Vec::new())),
5918                labels: Arc::new(Mutex::new(Vec::new())),
5919            });
5920
5921            let dispatcher = ExtensionDispatcher::new(
5922                Rc::clone(&runtime),
5923                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
5924                Arc::new(HttpConnector::with_defaults()),
5925                session as Arc<dyn ExtensionSession + Send + Sync>,
5926                Arc::new(NullUiHandler),
5927                PathBuf::from("."),
5928            );
5929
5930            for request in requests {
5931                dispatcher.dispatch_and_complete(request).await;
5932            }
5933
5934            while runtime.has_pending() {
5935                runtime.tick().await.expect("tick");
5936                runtime.drain_microtasks().await.expect("microtasks");
5937            }
5938
5939            runtime
5940                .eval(
5941                    r#"
5942                    if (!globalThis.setDone) throw new Error("setmodel not resolved");
5943                    if (!globalThis.thinkingSet) throw new Error("setthinkinglevel not resolved");
5944                "#,
5945                )
5946                .await
5947                .expect("verify camelCase aliases");
5948        });
5949    }
5950
5951    #[test]
5952    fn dispatcher_session_set_model_accepts_model_id_snake_case() {
5953        futures::executor::block_on(async {
5954            let runtime = Rc::new(
5955                PiJsRuntime::with_clock(DeterministicClock::new(0))
5956                    .await
5957                    .expect("runtime"),
5958            );
5959
5960            runtime
5961                .eval(
5962                    r#"
5963                    globalThis.setDone = false;
5964                    pi.session("set_model", { provider: "anthropic", model_id: "claude-opus-4-20250514" })
5965                        .then(() => { globalThis.setDone = true; });
5966                "#,
5967                )
5968                .await
5969                .expect("eval");
5970
5971            let requests = runtime.drain_hostcall_requests();
5972            assert_eq!(requests.len(), 1);
5973
5974            let state = Arc::new(Mutex::new(serde_json::json!({})));
5975            let session = Arc::new(TestSession {
5976                state: Arc::clone(&state),
5977                messages: Arc::new(Mutex::new(Vec::new())),
5978                entries: Arc::new(Mutex::new(Vec::new())),
5979                branch: Arc::new(Mutex::new(Vec::new())),
5980                name: Arc::new(Mutex::new(None)),
5981                custom_entries: Arc::new(Mutex::new(Vec::new())),
5982                labels: Arc::new(Mutex::new(Vec::new())),
5983            });
5984
5985            let dispatcher = ExtensionDispatcher::new(
5986                Rc::clone(&runtime),
5987                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
5988                Arc::new(HttpConnector::with_defaults()),
5989                session,
5990                Arc::new(NullUiHandler),
5991                PathBuf::from("."),
5992            );
5993
5994            for request in requests {
5995                dispatcher.dispatch_and_complete(request).await;
5996            }
5997
5998            while runtime.has_pending() {
5999                runtime.tick().await.expect("tick");
6000                runtime.drain_microtasks().await.expect("microtasks");
6001            }
6002
6003            runtime
6004                .eval(
6005                    r#"
6006                    if (!globalThis.setDone) throw new Error("set_model with model_id not resolved");
6007                "#,
6008                )
6009                .await
6010                .expect("verify model_id snake_case");
6011
6012            let final_state = state.lock().unwrap().clone();
6013            assert_eq!(
6014                final_state.get("modelId").and_then(Value::as_str),
6015                Some("claude-opus-4-20250514")
6016            );
6017        });
6018    }
6019
6020    #[test]
6021    fn dispatcher_session_set_thinking_level_accepts_alt_keys() {
6022        futures::executor::block_on(async {
6023            let runtime = Rc::new(
6024                PiJsRuntime::with_clock(DeterministicClock::new(0))
6025                    .await
6026                    .expect("runtime"),
6027            );
6028
6029            // Test thinkingLevel key
6030            runtime
6031                .eval(
6032                    r#"
6033                    globalThis.done1 = false;
6034                    globalThis.done2 = false;
6035                    pi.session("set_thinking_level", { thinkingLevel: "medium" })
6036                        .then(() => { globalThis.done1 = true; });
6037                    pi.session("set_thinking_level", { thinking_level: "low" })
6038                        .then(() => { globalThis.done2 = true; });
6039                "#,
6040                )
6041                .await
6042                .expect("eval");
6043
6044            let requests = runtime.drain_hostcall_requests();
6045            assert_eq!(requests.len(), 2);
6046
6047            let state = Arc::new(Mutex::new(serde_json::json!({})));
6048            let session = Arc::new(TestSession {
6049                state: Arc::clone(&state),
6050                messages: Arc::new(Mutex::new(Vec::new())),
6051                entries: Arc::new(Mutex::new(Vec::new())),
6052                branch: Arc::new(Mutex::new(Vec::new())),
6053                name: Arc::new(Mutex::new(None)),
6054                custom_entries: Arc::new(Mutex::new(Vec::new())),
6055                labels: Arc::new(Mutex::new(Vec::new())),
6056            });
6057
6058            let dispatcher = ExtensionDispatcher::new(
6059                Rc::clone(&runtime),
6060                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
6061                Arc::new(HttpConnector::with_defaults()),
6062                session,
6063                Arc::new(NullUiHandler),
6064                PathBuf::from("."),
6065            );
6066
6067            for request in requests {
6068                dispatcher.dispatch_and_complete(request).await;
6069            }
6070
6071            while runtime.has_pending() {
6072                runtime.tick().await.expect("tick");
6073                runtime.drain_microtasks().await.expect("microtasks");
6074            }
6075
6076            runtime
6077                .eval(
6078                    r#"
6079                    if (!globalThis.done1) throw new Error("thinkingLevel key not resolved");
6080                    if (!globalThis.done2) throw new Error("thinking_level key not resolved");
6081                "#,
6082                )
6083                .await
6084                .expect("verify alt keys");
6085
6086            // Last write wins, so "low" should be the final value
6087            let final_state = state.lock().unwrap().clone();
6088            assert_eq!(
6089                final_state.get("thinkingLevel").and_then(Value::as_str),
6090                Some("low")
6091            );
6092        });
6093    }
6094
6095    #[test]
6096    fn dispatcher_session_get_model_null_when_unset() {
6097        futures::executor::block_on(async {
6098            let runtime = Rc::new(
6099                PiJsRuntime::with_clock(DeterministicClock::new(0))
6100                    .await
6101                    .expect("runtime"),
6102            );
6103
6104            runtime
6105                .eval(
6106                    r#"
6107                    globalThis.model = "__unset__";
6108                    pi.session("get_model", {}).then((r) => { globalThis.model = r; });
6109                "#,
6110                )
6111                .await
6112                .expect("eval");
6113
6114            let requests = runtime.drain_hostcall_requests();
6115            assert_eq!(requests.len(), 1);
6116
6117            // NullSession returns (None, None) for get_model
6118            let dispatcher = build_dispatcher(Rc::clone(&runtime));
6119            for request in requests {
6120                dispatcher.dispatch_and_complete(request).await;
6121            }
6122
6123            while runtime.has_pending() {
6124                runtime.tick().await.expect("tick");
6125                runtime.drain_microtasks().await.expect("microtasks");
6126            }
6127
6128            runtime
6129                .eval(
6130                    r#"
6131                    if (!globalThis.model) throw new Error("get_model not resolved");
6132                    if (globalThis.model.provider !== null) {
6133                        throw new Error("Unset provider should be null, got: " + JSON.stringify(globalThis.model.provider));
6134                    }
6135                    if (globalThis.model.modelId !== null) {
6136                        throw new Error("Unset modelId should be null, got: " + JSON.stringify(globalThis.model.modelId));
6137                    }
6138                "#,
6139                )
6140                .await
6141                .expect("verify null model");
6142        });
6143    }
6144
6145    // ---- set_label tests ----
6146
6147    #[test]
6148    fn dispatcher_session_set_label_resolves_and_persists() {
6149        futures::executor::block_on(async {
6150            let runtime = Rc::new(
6151                PiJsRuntime::with_clock(DeterministicClock::new(0))
6152                    .await
6153                    .expect("runtime"),
6154            );
6155
6156            runtime
6157                .eval(
6158                    r#"
6159                    globalThis.result = "__unset__";
6160                    pi.session("set_label", { targetId: "msg-42", label: "important" })
6161                        .then((r) => { globalThis.result = r; });
6162                "#,
6163                )
6164                .await
6165                .expect("eval");
6166
6167            let requests = runtime.drain_hostcall_requests();
6168            assert_eq!(requests.len(), 1);
6169
6170            let labels: Arc<Mutex<Vec<LabelEntry>>> = Arc::new(Mutex::new(Vec::new()));
6171            let session = Arc::new(TestSession {
6172                state: Arc::new(Mutex::new(serde_json::json!({}))),
6173                messages: Arc::new(Mutex::new(Vec::new())),
6174                entries: Arc::new(Mutex::new(Vec::new())),
6175                branch: Arc::new(Mutex::new(Vec::new())),
6176                name: Arc::new(Mutex::new(None)),
6177                custom_entries: Arc::new(Mutex::new(Vec::new())),
6178                labels: Arc::clone(&labels),
6179            });
6180
6181            let dispatcher = ExtensionDispatcher::new(
6182                Rc::clone(&runtime),
6183                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
6184                Arc::new(HttpConnector::with_defaults()),
6185                session,
6186                Arc::new(NullUiHandler),
6187                PathBuf::from("."),
6188            );
6189
6190            for request in requests {
6191                dispatcher.dispatch_and_complete(request).await;
6192            }
6193
6194            while runtime.has_pending() {
6195                runtime.tick().await.expect("tick");
6196                runtime.drain_microtasks().await.expect("microtasks");
6197            }
6198
6199            // Verify set_label was called with correct args
6200            let captured = labels.lock().unwrap();
6201            assert_eq!(captured.len(), 1);
6202            assert_eq!(captured[0].0, "msg-42");
6203            assert_eq!(captured[0].1.as_deref(), Some("important"));
6204            drop(captured);
6205        });
6206    }
6207
6208    #[test]
6209    fn dispatcher_session_set_label_remove_label_with_null() {
6210        futures::executor::block_on(async {
6211            let runtime = Rc::new(
6212                PiJsRuntime::with_clock(DeterministicClock::new(0))
6213                    .await
6214                    .expect("runtime"),
6215            );
6216
6217            runtime
6218                .eval(
6219                    r#"
6220                    globalThis.result = "__unset__";
6221                    pi.session("set_label", { targetId: "msg-99" })
6222                        .then((r) => { globalThis.result = r; });
6223                "#,
6224                )
6225                .await
6226                .expect("eval");
6227
6228            let requests = runtime.drain_hostcall_requests();
6229            assert_eq!(requests.len(), 1);
6230
6231            let labels: Arc<Mutex<Vec<LabelEntry>>> = Arc::new(Mutex::new(Vec::new()));
6232            let session = Arc::new(TestSession {
6233                state: Arc::new(Mutex::new(serde_json::json!({}))),
6234                messages: Arc::new(Mutex::new(Vec::new())),
6235                entries: Arc::new(Mutex::new(Vec::new())),
6236                branch: Arc::new(Mutex::new(Vec::new())),
6237                name: Arc::new(Mutex::new(None)),
6238                custom_entries: Arc::new(Mutex::new(Vec::new())),
6239                labels: Arc::clone(&labels),
6240            });
6241
6242            let dispatcher = ExtensionDispatcher::new(
6243                Rc::clone(&runtime),
6244                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
6245                Arc::new(HttpConnector::with_defaults()),
6246                session,
6247                Arc::new(NullUiHandler),
6248                PathBuf::from("."),
6249            );
6250
6251            for request in requests {
6252                dispatcher.dispatch_and_complete(request).await;
6253            }
6254
6255            while runtime.has_pending() {
6256                runtime.tick().await.expect("tick");
6257                runtime.drain_microtasks().await.expect("microtasks");
6258            }
6259
6260            // Verify set_label was called with None label (removal)
6261            let captured = labels.lock().unwrap();
6262            assert_eq!(captured.len(), 1);
6263            assert_eq!(captured[0].0, "msg-99");
6264            assert!(captured[0].1.is_none());
6265            drop(captured);
6266        });
6267    }
6268
6269    #[test]
6270    fn dispatcher_session_set_label_missing_target_id_rejects() {
6271        futures::executor::block_on(async {
6272            let runtime = Rc::new(
6273                PiJsRuntime::with_clock(DeterministicClock::new(0))
6274                    .await
6275                    .expect("runtime"),
6276            );
6277
6278            runtime
6279                .eval(
6280                    r#"
6281                    globalThis.errMsg = "";
6282                    pi.session("set_label", { label: "orphaned" })
6283                        .then(() => { globalThis.errMsg = "should_not_resolve"; })
6284                        .catch((e) => { globalThis.errMsg = e.message || String(e); });
6285                "#,
6286                )
6287                .await
6288                .expect("eval");
6289
6290            let requests = runtime.drain_hostcall_requests();
6291            assert_eq!(requests.len(), 1);
6292
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.errMsg || globalThis.errMsg === "should_not_resolve") {
6307                        throw new Error("Expected rejection, got: " + globalThis.errMsg);
6308                    }
6309                    if (!globalThis.errMsg.includes("targetId")) {
6310                        throw new Error("Expected error about targetId, got: " + globalThis.errMsg);
6311                    }
6312                "#,
6313                )
6314                .await
6315                .expect("verify rejection");
6316        });
6317    }
6318
6319    #[test]
6320    fn dispatcher_session_set_label_accepts_snake_case_target_id() {
6321        futures::executor::block_on(async {
6322            let runtime = Rc::new(
6323                PiJsRuntime::with_clock(DeterministicClock::new(0))
6324                    .await
6325                    .expect("runtime"),
6326            );
6327
6328            runtime
6329                .eval(
6330                    r#"
6331                    globalThis.result = "__unset__";
6332                    pi.session("set_label", { target_id: "msg-77", label: "reviewed" })
6333                        .then((r) => { globalThis.result = r; });
6334                "#,
6335                )
6336                .await
6337                .expect("eval");
6338
6339            let requests = runtime.drain_hostcall_requests();
6340            assert_eq!(requests.len(), 1);
6341
6342            let labels: Arc<Mutex<Vec<LabelEntry>>> = Arc::new(Mutex::new(Vec::new()));
6343            let session = Arc::new(TestSession {
6344                state: Arc::new(Mutex::new(serde_json::json!({}))),
6345                messages: Arc::new(Mutex::new(Vec::new())),
6346                entries: Arc::new(Mutex::new(Vec::new())),
6347                branch: Arc::new(Mutex::new(Vec::new())),
6348                name: Arc::new(Mutex::new(None)),
6349                custom_entries: Arc::new(Mutex::new(Vec::new())),
6350                labels: Arc::clone(&labels),
6351            });
6352
6353            let dispatcher = ExtensionDispatcher::new(
6354                Rc::clone(&runtime),
6355                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
6356                Arc::new(HttpConnector::with_defaults()),
6357                session,
6358                Arc::new(NullUiHandler),
6359                PathBuf::from("."),
6360            );
6361
6362            for request in requests {
6363                dispatcher.dispatch_and_complete(request).await;
6364            }
6365
6366            while runtime.has_pending() {
6367                runtime.tick().await.expect("tick");
6368                runtime.drain_microtasks().await.expect("microtasks");
6369            }
6370
6371            let captured = labels.lock().unwrap();
6372            assert_eq!(captured.len(), 1);
6373            assert_eq!(captured[0].0, "msg-77");
6374            assert_eq!(captured[0].1.as_deref(), Some("reviewed"));
6375            drop(captured);
6376        });
6377    }
6378
6379    #[test]
6380    fn dispatcher_session_set_label_camel_case_op_alias() {
6381        futures::executor::block_on(async {
6382            let runtime = Rc::new(
6383                PiJsRuntime::with_clock(DeterministicClock::new(0))
6384                    .await
6385                    .expect("runtime"),
6386            );
6387
6388            // Use "setLabel" style (gets lowercased to "setlabel" which matches)
6389            runtime
6390                .eval(
6391                    r#"
6392                    globalThis.result = "__unset__";
6393                    pi.session("setLabel", { targetId: "entry-5", label: "flagged" })
6394                        .then((r) => { globalThis.result = r; });
6395                "#,
6396                )
6397                .await
6398                .expect("eval");
6399
6400            let requests = runtime.drain_hostcall_requests();
6401            assert_eq!(requests.len(), 1);
6402
6403            let labels: Arc<Mutex<Vec<LabelEntry>>> = Arc::new(Mutex::new(Vec::new()));
6404            let session = Arc::new(TestSession {
6405                state: Arc::new(Mutex::new(serde_json::json!({}))),
6406                messages: Arc::new(Mutex::new(Vec::new())),
6407                entries: Arc::new(Mutex::new(Vec::new())),
6408                branch: Arc::new(Mutex::new(Vec::new())),
6409                name: Arc::new(Mutex::new(None)),
6410                custom_entries: Arc::new(Mutex::new(Vec::new())),
6411                labels: Arc::clone(&labels),
6412            });
6413
6414            let dispatcher = ExtensionDispatcher::new(
6415                Rc::clone(&runtime),
6416                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
6417                Arc::new(HttpConnector::with_defaults()),
6418                session,
6419                Arc::new(NullUiHandler),
6420                PathBuf::from("."),
6421            );
6422
6423            for request in requests {
6424                dispatcher.dispatch_and_complete(request).await;
6425            }
6426
6427            while runtime.has_pending() {
6428                runtime.tick().await.expect("tick");
6429                runtime.drain_microtasks().await.expect("microtasks");
6430            }
6431
6432            let captured = labels.lock().unwrap();
6433            assert_eq!(captured.len(), 1);
6434            assert_eq!(captured[0].0, "entry-5");
6435            assert_eq!(captured[0].1.as_deref(), Some("flagged"));
6436            drop(captured);
6437        });
6438    }
6439
6440    // ---- Tool conformance tests ----
6441
6442    #[test]
6443    fn dispatcher_tool_write_creates_file_and_resolves() {
6444        futures::executor::block_on(async {
6445            let temp_dir = tempfile::tempdir().expect("tempdir");
6446
6447            let runtime = Rc::new(
6448                PiJsRuntime::with_clock(DeterministicClock::new(0))
6449                    .await
6450                    .expect("runtime"),
6451            );
6452
6453            let file_path = temp_dir.path().join("output.txt");
6454            let file_path_str = file_path.display().to_string().replace('\\', "\\\\");
6455            let script = format!(
6456                r#"
6457                globalThis.result = null;
6458                pi.tool("write", {{ path: "{file_path_str}", content: "written by extension" }})
6459                    .then((r) => {{ globalThis.result = r; }});
6460            "#
6461            );
6462            runtime.eval(&script).await.expect("eval");
6463
6464            let requests = runtime.drain_hostcall_requests();
6465            assert_eq!(requests.len(), 1);
6466
6467            let dispatcher = ExtensionDispatcher::new(
6468                Rc::clone(&runtime),
6469                Arc::new(ToolRegistry::new(&["write"], temp_dir.path(), None)),
6470                Arc::new(HttpConnector::with_defaults()),
6471                Arc::new(NullSession),
6472                Arc::new(NullUiHandler),
6473                temp_dir.path().to_path_buf(),
6474            );
6475
6476            for request in requests {
6477                dispatcher.dispatch_and_complete(request).await;
6478            }
6479
6480            while runtime.has_pending() {
6481                runtime.tick().await.expect("tick");
6482                runtime.drain_microtasks().await.expect("microtasks");
6483            }
6484
6485            // Verify file was created
6486            assert!(file_path.exists());
6487            let content = std::fs::read_to_string(&file_path).expect("read file");
6488            assert_eq!(content, "written by extension");
6489        });
6490    }
6491
6492    #[test]
6493    fn dispatcher_tool_ls_lists_directory() {
6494        futures::executor::block_on(async {
6495            let temp_dir = tempfile::tempdir().expect("tempdir");
6496            std::fs::write(temp_dir.path().join("alpha.txt"), "a").expect("write");
6497            std::fs::write(temp_dir.path().join("beta.txt"), "b").expect("write");
6498
6499            let runtime = Rc::new(
6500                PiJsRuntime::with_clock(DeterministicClock::new(0))
6501                    .await
6502                    .expect("runtime"),
6503            );
6504
6505            runtime
6506                .eval(
6507                    r#"
6508                    globalThis.result = null;
6509                    pi.tool("ls", { path: "." })
6510                        .then((r) => { globalThis.result = r; });
6511                "#,
6512                )
6513                .await
6514                .expect("eval");
6515
6516            let requests = runtime.drain_hostcall_requests();
6517            assert_eq!(requests.len(), 1);
6518
6519            let dispatcher = ExtensionDispatcher::new(
6520                Rc::clone(&runtime),
6521                Arc::new(ToolRegistry::new(&["ls"], temp_dir.path(), None)),
6522                Arc::new(HttpConnector::with_defaults()),
6523                Arc::new(NullSession),
6524                Arc::new(NullUiHandler),
6525                temp_dir.path().to_path_buf(),
6526            );
6527
6528            for request in requests {
6529                dispatcher.dispatch_and_complete(request).await;
6530            }
6531
6532            while runtime.has_pending() {
6533                runtime.tick().await.expect("tick");
6534                runtime.drain_microtasks().await.expect("microtasks");
6535            }
6536
6537            runtime
6538                .eval(
6539                    r#"
6540                    if (globalThis.result === null) throw new Error("ls not resolved");
6541                    let s = JSON.stringify(globalThis.result);
6542                    if (!s.includes("alpha.txt") || !s.includes("beta.txt")) {
6543                        throw new Error("Missing files in ls output: " + s);
6544                    }
6545                "#,
6546                )
6547                .await
6548                .expect("verify ls result");
6549        });
6550    }
6551
6552    #[test]
6553    fn dispatcher_tool_grep_searches_content() {
6554        futures::executor::block_on(async {
6555            let temp_dir = tempfile::tempdir().expect("tempdir");
6556            std::fs::write(
6557                temp_dir.path().join("data.txt"),
6558                "line one\nline two\nline three",
6559            )
6560            .expect("write");
6561
6562            let runtime = Rc::new(
6563                PiJsRuntime::with_clock(DeterministicClock::new(0))
6564                    .await
6565                    .expect("runtime"),
6566            );
6567
6568            let dir = temp_dir.path().display().to_string().replace('\\', "\\\\");
6569            let script = format!(
6570                r#"
6571                globalThis.result = null;
6572                pi.tool("grep", {{ pattern: "two", path: "{dir}" }})
6573                    .then((r) => {{ globalThis.result = r; }});
6574            "#
6575            );
6576            runtime.eval(&script).await.expect("eval");
6577
6578            let requests = runtime.drain_hostcall_requests();
6579            assert_eq!(requests.len(), 1);
6580
6581            let dispatcher = ExtensionDispatcher::new(
6582                Rc::clone(&runtime),
6583                Arc::new(ToolRegistry::new(&["grep"], temp_dir.path(), None)),
6584                Arc::new(HttpConnector::with_defaults()),
6585                Arc::new(NullSession),
6586                Arc::new(NullUiHandler),
6587                temp_dir.path().to_path_buf(),
6588            );
6589
6590            for request in requests {
6591                dispatcher.dispatch_and_complete(request).await;
6592            }
6593
6594            while runtime.has_pending() {
6595                runtime.tick().await.expect("tick");
6596                runtime.drain_microtasks().await.expect("microtasks");
6597            }
6598
6599            runtime
6600                .eval(
6601                    r#"
6602                    if (globalThis.result === null) throw new Error("grep not resolved");
6603                    let s = JSON.stringify(globalThis.result);
6604                    if (!s.includes("two")) {
6605                        throw new Error("grep should find 'two': " + s);
6606                    }
6607                "#,
6608                )
6609                .await
6610                .expect("verify grep result");
6611        });
6612    }
6613
6614    #[test]
6615    fn dispatcher_tool_edit_modifies_file_content() {
6616        futures::executor::block_on(async {
6617            let temp_dir = tempfile::tempdir().expect("tempdir");
6618            std::fs::write(temp_dir.path().join("target.txt"), "old text here").expect("write");
6619
6620            let runtime = Rc::new(
6621                PiJsRuntime::with_clock(DeterministicClock::new(0))
6622                    .await
6623                    .expect("runtime"),
6624            );
6625
6626            runtime
6627                .eval(
6628                    r#"
6629                    globalThis.result = null;
6630                    pi.tool("edit", { path: "target.txt", oldText: "old text", newText: "new text" })
6631                        .then((r) => { globalThis.result = r; });
6632                "#,
6633                )
6634                .await
6635                .expect("eval");
6636
6637            let requests = runtime.drain_hostcall_requests();
6638            assert_eq!(requests.len(), 1);
6639
6640            let dispatcher = ExtensionDispatcher::new(
6641                Rc::clone(&runtime),
6642                Arc::new(ToolRegistry::new(&["edit"], temp_dir.path(), None)),
6643                Arc::new(HttpConnector::with_defaults()),
6644                Arc::new(NullSession),
6645                Arc::new(NullUiHandler),
6646                temp_dir.path().to_path_buf(),
6647            );
6648
6649            for request in requests {
6650                dispatcher.dispatch_and_complete(request).await;
6651            }
6652
6653            while runtime.has_pending() {
6654                runtime.tick().await.expect("tick");
6655                runtime.drain_microtasks().await.expect("microtasks");
6656            }
6657
6658            let content =
6659                std::fs::read_to_string(temp_dir.path().join("target.txt")).expect("read file");
6660            assert!(
6661                content.contains("new text"),
6662                "Expected edited content, got: {content}"
6663            );
6664        });
6665    }
6666
6667    #[test]
6668    fn dispatcher_tool_find_discovers_files() {
6669        futures::executor::block_on(async {
6670            let temp_dir = tempfile::tempdir().expect("tempdir");
6671            std::fs::write(temp_dir.path().join("code.rs"), "fn main(){}").expect("write");
6672            std::fs::write(temp_dir.path().join("data.json"), "{}").expect("write");
6673
6674            let runtime = Rc::new(
6675                PiJsRuntime::with_clock(DeterministicClock::new(0))
6676                    .await
6677                    .expect("runtime"),
6678            );
6679
6680            runtime
6681                .eval(
6682                    r#"
6683                    globalThis.result = null;
6684                    pi.tool("find", { pattern: "*.rs" })
6685                        .then((r) => { globalThis.result = r; });
6686                "#,
6687                )
6688                .await
6689                .expect("eval");
6690
6691            let requests = runtime.drain_hostcall_requests();
6692            assert_eq!(requests.len(), 1);
6693
6694            let dispatcher = ExtensionDispatcher::new(
6695                Rc::clone(&runtime),
6696                Arc::new(ToolRegistry::new(&["find"], temp_dir.path(), None)),
6697                Arc::new(HttpConnector::with_defaults()),
6698                Arc::new(NullSession),
6699                Arc::new(NullUiHandler),
6700                temp_dir.path().to_path_buf(),
6701            );
6702
6703            for request in requests {
6704                dispatcher.dispatch_and_complete(request).await;
6705            }
6706
6707            while runtime.has_pending() {
6708                runtime.tick().await.expect("tick");
6709                runtime.drain_microtasks().await.expect("microtasks");
6710            }
6711
6712            runtime
6713                .eval(
6714                    r#"
6715                    if (globalThis.result === null) throw new Error("find not resolved");
6716                    let s = JSON.stringify(globalThis.result);
6717                    if (!s.includes("code.rs")) {
6718                        throw new Error("find should discover code.rs: " + s);
6719                    }
6720                    if (s.includes("data.json")) {
6721                        throw new Error("find *.rs should not include data.json: " + s);
6722                    }
6723                "#,
6724                )
6725                .await
6726                .expect("verify find result");
6727        });
6728    }
6729
6730    #[test]
6731    fn dispatcher_tool_multiple_tools_sequentially() {
6732        futures::executor::block_on(async {
6733            let temp_dir = tempfile::tempdir().expect("tempdir");
6734            std::fs::write(temp_dir.path().join("file.txt"), "hello").expect("write");
6735
6736            let runtime = Rc::new(
6737                PiJsRuntime::with_clock(DeterministicClock::new(0))
6738                    .await
6739                    .expect("runtime"),
6740            );
6741
6742            // Queue two tool calls
6743            runtime
6744                .eval(
6745                    r#"
6746                    globalThis.readResult = null;
6747                    globalThis.lsResult = null;
6748                    pi.tool("read", { path: "file.txt" })
6749                        .then((r) => { globalThis.readResult = r; });
6750                    pi.tool("ls", { path: "." })
6751                        .then((r) => { globalThis.lsResult = r; });
6752                "#,
6753                )
6754                .await
6755                .expect("eval");
6756
6757            let requests = runtime.drain_hostcall_requests();
6758            assert_eq!(requests.len(), 2);
6759
6760            let dispatcher = ExtensionDispatcher::new(
6761                Rc::clone(&runtime),
6762                Arc::new(ToolRegistry::new(&["read", "ls"], temp_dir.path(), None)),
6763                Arc::new(HttpConnector::with_defaults()),
6764                Arc::new(NullSession),
6765                Arc::new(NullUiHandler),
6766                temp_dir.path().to_path_buf(),
6767            );
6768
6769            for request in requests {
6770                dispatcher.dispatch_and_complete(request).await;
6771            }
6772
6773            while runtime.has_pending() {
6774                runtime.tick().await.expect("tick");
6775                runtime.drain_microtasks().await.expect("microtasks");
6776            }
6777
6778            runtime
6779                .eval(
6780                    r#"
6781                    if (globalThis.readResult === null) throw new Error("read not resolved");
6782                    if (globalThis.lsResult === null) throw new Error("ls not resolved");
6783                "#,
6784                )
6785                .await
6786                .expect("verify both tools resolved");
6787        });
6788    }
6789
6790    #[test]
6791    fn dispatcher_tool_error_propagates_to_js() {
6792        futures::executor::block_on(async {
6793            let temp_dir = tempfile::tempdir().expect("tempdir");
6794
6795            let runtime = Rc::new(
6796                PiJsRuntime::with_clock(DeterministicClock::new(0))
6797                    .await
6798                    .expect("runtime"),
6799            );
6800
6801            // Try to read a non-existent file
6802            runtime
6803                .eval(
6804                    r#"
6805                    globalThis.errMsg = "";
6806                    pi.tool("read", { path: "nonexistent_file.txt" })
6807                        .then(() => { globalThis.errMsg = "should_not_resolve"; })
6808                        .catch((e) => { globalThis.errMsg = e.message || String(e); });
6809                "#,
6810                )
6811                .await
6812                .expect("eval");
6813
6814            let requests = runtime.drain_hostcall_requests();
6815            assert_eq!(requests.len(), 1);
6816
6817            let dispatcher = ExtensionDispatcher::new(
6818                Rc::clone(&runtime),
6819                Arc::new(ToolRegistry::new(&["read"], temp_dir.path(), None)),
6820                Arc::new(HttpConnector::with_defaults()),
6821                Arc::new(NullSession),
6822                Arc::new(NullUiHandler),
6823                temp_dir.path().to_path_buf(),
6824            );
6825
6826            for request in requests {
6827                dispatcher.dispatch_and_complete(request).await;
6828            }
6829
6830            while runtime.has_pending() {
6831                runtime.tick().await.expect("tick");
6832                runtime.drain_microtasks().await.expect("microtasks");
6833            }
6834
6835            // The read tool may resolve with an error content rather than rejecting.
6836            // Either way, the dispatcher shouldn't panic.
6837            runtime
6838                .eval(
6839                    r#"
6840                    // Just verify something happened - error propagation is tool-specific
6841                    if (globalThis.errMsg === "" && globalThis.result === null) {
6842                        throw new Error("Neither resolved nor rejected");
6843                    }
6844                "#,
6845                )
6846                .await
6847                .expect("verify tool error handling");
6848        });
6849    }
6850
6851    // ---- HTTP conformance tests ----
6852
6853    fn spawn_http_server_with_status(status: u16, body: &'static str) -> std::net::SocketAddr {
6854        let listener = TcpListener::bind("127.0.0.1:0").expect("bind http server");
6855        let addr = listener.local_addr().expect("server addr");
6856        thread::spawn(move || {
6857            if let Ok((mut stream, _)) = listener.accept() {
6858                let mut buf = [0u8; 1024];
6859                let _ = stream.read(&mut buf);
6860                let response = format!(
6861                    "HTTP/1.1 {status} Error\r\nContent-Length: {len}\r\nContent-Type: text/plain\r\n\r\n{body}",
6862                    status = status,
6863                    len = body.len(),
6864                    body = body,
6865                );
6866                let _ = stream.write_all(response.as_bytes());
6867            }
6868        });
6869        addr
6870    }
6871
6872    #[test]
6873    #[cfg(unix)] // std::net::TcpListener + asupersync interop fails on Windows
6874    fn dispatcher_http_post_sends_body() {
6875        futures::executor::block_on(async {
6876            let addr = spawn_http_server("post-ok");
6877            let url = format!("http://{addr}/data");
6878
6879            let runtime = Rc::new(
6880                PiJsRuntime::with_clock(DeterministicClock::new(0))
6881                    .await
6882                    .expect("runtime"),
6883            );
6884
6885            let script = format!(
6886                r#"
6887                globalThis.result = null;
6888                pi.http({{ url: "{url}", method: "POST", body: "test-payload" }})
6889                    .then((r) => {{ globalThis.result = r; }});
6890            "#
6891            );
6892            runtime.eval(&script).await.expect("eval");
6893
6894            let requests = runtime.drain_hostcall_requests();
6895            assert_eq!(requests.len(), 1);
6896
6897            let http_connector = HttpConnector::new(HttpConnectorConfig {
6898                require_tls: false,
6899                ..Default::default()
6900            });
6901            let dispatcher = ExtensionDispatcher::new(
6902                Rc::clone(&runtime),
6903                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
6904                Arc::new(http_connector),
6905                Arc::new(NullSession),
6906                Arc::new(NullUiHandler),
6907                PathBuf::from("."),
6908            );
6909
6910            for request in requests {
6911                dispatcher.dispatch_and_complete(request).await;
6912            }
6913
6914            while runtime.has_pending() {
6915                runtime.tick().await.expect("tick");
6916                runtime.drain_microtasks().await.expect("microtasks");
6917            }
6918
6919            runtime
6920                .eval(
6921                    r#"
6922                    if (globalThis.result === null) throw new Error("POST not resolved");
6923                    if (globalThis.result.status !== 200) {
6924                        throw new Error("Expected 200, got: " + globalThis.result.status);
6925                    }
6926                "#,
6927                )
6928                .await
6929                .expect("verify POST result");
6930        });
6931    }
6932
6933    #[test]
6934    fn dispatcher_http_missing_url_rejects() {
6935        futures::executor::block_on(async {
6936            let runtime = Rc::new(
6937                PiJsRuntime::with_clock(DeterministicClock::new(0))
6938                    .await
6939                    .expect("runtime"),
6940            );
6941
6942            runtime
6943                .eval(
6944                    r#"
6945                    globalThis.errMsg = "";
6946                    pi.http({ method: "GET" })
6947                        .then(() => { globalThis.errMsg = "should_not_resolve"; })
6948                        .catch((e) => { globalThis.errMsg = e.message || String(e); });
6949                "#,
6950                )
6951                .await
6952                .expect("eval");
6953
6954            let requests = runtime.drain_hostcall_requests();
6955            assert_eq!(requests.len(), 1);
6956
6957            let http_connector = HttpConnector::new(HttpConnectorConfig {
6958                require_tls: false,
6959                ..Default::default()
6960            });
6961            let dispatcher = ExtensionDispatcher::new(
6962                Rc::clone(&runtime),
6963                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
6964                Arc::new(http_connector),
6965                Arc::new(NullSession),
6966                Arc::new(NullUiHandler),
6967                PathBuf::from("."),
6968            );
6969
6970            for request in requests {
6971                dispatcher.dispatch_and_complete(request).await;
6972            }
6973
6974            while runtime.has_pending() {
6975                runtime.tick().await.expect("tick");
6976                runtime.drain_microtasks().await.expect("microtasks");
6977            }
6978
6979            runtime
6980                .eval(
6981                    r#"
6982                    if (globalThis.errMsg === "should_not_resolve") {
6983                        throw new Error("Expected rejection for missing URL");
6984                    }
6985                "#,
6986                )
6987                .await
6988                .expect("verify missing URL rejection");
6989        });
6990    }
6991
6992    #[test]
6993    fn dispatcher_http_custom_headers() {
6994        futures::executor::block_on(async {
6995            let addr = spawn_http_server("headers-ok");
6996            let url = format!("http://{addr}/headers");
6997
6998            let runtime = Rc::new(
6999                PiJsRuntime::with_clock(DeterministicClock::new(0))
7000                    .await
7001                    .expect("runtime"),
7002            );
7003
7004            let script = format!(
7005                r#"
7006                globalThis.result = null;
7007                pi.http({{
7008                    url: "{url}",
7009                    method: "GET",
7010                    headers: {{ "X-Custom": "test-value", "Accept": "application/json" }}
7011                }}).then((r) => {{ globalThis.result = r; }});
7012            "#
7013            );
7014            runtime.eval(&script).await.expect("eval");
7015
7016            let requests = runtime.drain_hostcall_requests();
7017            assert_eq!(requests.len(), 1);
7018
7019            let http_connector = HttpConnector::new(HttpConnectorConfig {
7020                require_tls: false,
7021                ..Default::default()
7022            });
7023            let dispatcher = ExtensionDispatcher::new(
7024                Rc::clone(&runtime),
7025                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
7026                Arc::new(http_connector),
7027                Arc::new(NullSession),
7028                Arc::new(NullUiHandler),
7029                PathBuf::from("."),
7030            );
7031
7032            for request in requests {
7033                dispatcher.dispatch_and_complete(request).await;
7034            }
7035
7036            while runtime.has_pending() {
7037                runtime.tick().await.expect("tick");
7038                runtime.drain_microtasks().await.expect("microtasks");
7039            }
7040
7041            runtime
7042                .eval(
7043                    r#"
7044                    if (globalThis.result === null) throw new Error("HTTP not resolved");
7045                    if (globalThis.result.status !== 200) {
7046                        throw new Error("Expected 200, got: " + globalThis.result.status);
7047                    }
7048                "#,
7049                )
7050                .await
7051                .expect("verify headers request");
7052        });
7053    }
7054
7055    #[test]
7056    fn dispatcher_http_connection_refused_rejects() {
7057        futures::executor::block_on(async {
7058            let runtime = Rc::new(
7059                PiJsRuntime::with_clock(DeterministicClock::new(0))
7060                    .await
7061                    .expect("runtime"),
7062            );
7063
7064            // Use a port that definitely has nothing listening
7065            runtime
7066                .eval(
7067                    r#"
7068                    globalThis.errMsg = "";
7069                    pi.http({ url: "http://127.0.0.1:1/never", method: "GET" })
7070                        .then(() => { globalThis.errMsg = "should_not_resolve"; })
7071                        .catch((e) => { globalThis.errMsg = e.message || String(e); });
7072                "#,
7073                )
7074                .await
7075                .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.errMsg === "should_not_resolve") {
7106                        throw new Error("Expected rejection for connection refused");
7107                    }
7108                "#,
7109                )
7110                .await
7111                .expect("verify connection refused");
7112        });
7113    }
7114
7115    // ---- UI conformance tests ----
7116
7117    #[test]
7118    fn dispatcher_ui_spinner_method() {
7119        futures::executor::block_on(async {
7120            let runtime = Rc::new(
7121                PiJsRuntime::with_clock(DeterministicClock::new(0))
7122                    .await
7123                    .expect("runtime"),
7124            );
7125
7126            runtime
7127                .eval(
7128                    r#"
7129                    globalThis.result = null;
7130                    pi.ui("spinner", { text: "Loading...", visible: true })
7131                        .then((r) => { globalThis.result = r; });
7132                "#,
7133                )
7134                .await
7135                .expect("eval");
7136
7137            let requests = runtime.drain_hostcall_requests();
7138            assert_eq!(requests.len(), 1);
7139
7140            let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
7141            let ui_handler = Arc::new(TestUiHandler {
7142                captured: Arc::clone(&captured),
7143                response_value: serde_json::json!({ "acknowledged": true }),
7144            });
7145
7146            let dispatcher = ExtensionDispatcher::new(
7147                Rc::clone(&runtime),
7148                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
7149                Arc::new(HttpConnector::with_defaults()),
7150                Arc::new(NullSession),
7151                ui_handler,
7152                PathBuf::from("."),
7153            );
7154
7155            for request in requests {
7156                dispatcher.dispatch_and_complete(request).await;
7157            }
7158
7159            while runtime.has_pending() {
7160                runtime.tick().await.expect("tick");
7161                runtime.drain_microtasks().await.expect("microtasks");
7162            }
7163
7164            let reqs = captured.lock().unwrap().clone();
7165            assert_eq!(reqs.len(), 1);
7166            assert_eq!(reqs[0].method, "spinner");
7167            assert_eq!(reqs[0].payload["text"], "Loading...");
7168        });
7169    }
7170
7171    #[test]
7172    fn dispatcher_ui_progress_method() {
7173        futures::executor::block_on(async {
7174            let runtime = Rc::new(
7175                PiJsRuntime::with_clock(DeterministicClock::new(0))
7176                    .await
7177                    .expect("runtime"),
7178            );
7179
7180            runtime
7181                .eval(
7182                    r#"
7183                    globalThis.result = null;
7184                    pi.ui("progress", { current: 50, total: 100, label: "Processing" })
7185                        .then((r) => { globalThis.result = r; });
7186                "#,
7187                )
7188                .await
7189                .expect("eval");
7190
7191            let requests = runtime.drain_hostcall_requests();
7192            assert_eq!(requests.len(), 1);
7193
7194            let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
7195            let ui_handler = Arc::new(TestUiHandler {
7196                captured: Arc::clone(&captured),
7197                response_value: Value::Null,
7198            });
7199
7200            let dispatcher = ExtensionDispatcher::new(
7201                Rc::clone(&runtime),
7202                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
7203                Arc::new(HttpConnector::with_defaults()),
7204                Arc::new(NullSession),
7205                ui_handler,
7206                PathBuf::from("."),
7207            );
7208
7209            for request in requests {
7210                dispatcher.dispatch_and_complete(request).await;
7211            }
7212
7213            while runtime.has_pending() {
7214                runtime.tick().await.expect("tick");
7215                runtime.drain_microtasks().await.expect("microtasks");
7216            }
7217
7218            let reqs = captured.lock().unwrap().clone();
7219            assert_eq!(reqs.len(), 1);
7220            assert_eq!(reqs[0].method, "progress");
7221            assert_eq!(reqs[0].payload["current"], 50);
7222            assert_eq!(reqs[0].payload["total"], 100);
7223        });
7224    }
7225
7226    #[test]
7227    fn dispatcher_ui_notification_method() {
7228        futures::executor::block_on(async {
7229            let runtime = Rc::new(
7230                PiJsRuntime::with_clock(DeterministicClock::new(0))
7231                    .await
7232                    .expect("runtime"),
7233            );
7234
7235            runtime
7236                .eval(
7237                    r#"
7238                    globalThis.result = null;
7239                    pi.ui("notification", { message: "Task complete!", level: "info" })
7240                        .then((r) => { globalThis.result = r; });
7241                "#,
7242                )
7243                .await
7244                .expect("eval");
7245
7246            let requests = runtime.drain_hostcall_requests();
7247            assert_eq!(requests.len(), 1);
7248
7249            let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
7250            let ui_handler = Arc::new(TestUiHandler {
7251                captured: Arc::clone(&captured),
7252                response_value: serde_json::json!({ "shown": true }),
7253            });
7254
7255            let dispatcher = ExtensionDispatcher::new(
7256                Rc::clone(&runtime),
7257                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
7258                Arc::new(HttpConnector::with_defaults()),
7259                Arc::new(NullSession),
7260                ui_handler,
7261                PathBuf::from("."),
7262            );
7263
7264            for request in requests {
7265                dispatcher.dispatch_and_complete(request).await;
7266            }
7267
7268            while runtime.has_pending() {
7269                runtime.tick().await.expect("tick");
7270                runtime.drain_microtasks().await.expect("microtasks");
7271            }
7272
7273            let reqs = captured.lock().unwrap().clone();
7274            assert_eq!(reqs.len(), 1);
7275            assert_eq!(reqs[0].method, "notification");
7276            assert_eq!(reqs[0].payload["message"], "Task complete!");
7277            assert_eq!(reqs[0].payload["level"], "info");
7278        });
7279    }
7280
7281    #[test]
7282    fn dispatcher_ui_null_handler_returns_null() {
7283        futures::executor::block_on(async {
7284            let runtime = Rc::new(
7285                PiJsRuntime::with_clock(DeterministicClock::new(0))
7286                    .await
7287                    .expect("runtime"),
7288            );
7289
7290            runtime
7291                .eval(
7292                    r#"
7293                    globalThis.result = "__unset__";
7294                    pi.ui("any_method", { key: "value" })
7295                        .then((r) => { globalThis.result = r; });
7296                "#,
7297                )
7298                .await
7299                .expect("eval");
7300
7301            let requests = runtime.drain_hostcall_requests();
7302            assert_eq!(requests.len(), 1);
7303
7304            // Use NullUiHandler - returns None which maps to null
7305            let dispatcher = build_dispatcher(Rc::clone(&runtime));
7306            for request in requests {
7307                dispatcher.dispatch_and_complete(request).await;
7308            }
7309
7310            while runtime.has_pending() {
7311                runtime.tick().await.expect("tick");
7312                runtime.drain_microtasks().await.expect("microtasks");
7313            }
7314
7315            runtime
7316                .eval(
7317                    r#"
7318                    if (globalThis.result === "__unset__") throw new Error("UI not resolved");
7319                    if (globalThis.result !== null) {
7320                        throw new Error("Expected null from NullHandler, got: " + JSON.stringify(globalThis.result));
7321                    }
7322                "#,
7323                )
7324                .await
7325                .expect("verify null UI handler");
7326        });
7327    }
7328
7329    #[test]
7330    fn dispatcher_ui_multiple_calls_captured() {
7331        futures::executor::block_on(async {
7332            let runtime = Rc::new(
7333                PiJsRuntime::with_clock(DeterministicClock::new(0))
7334                    .await
7335                    .expect("runtime"),
7336            );
7337
7338            runtime
7339                .eval(
7340                    r#"
7341                    globalThis.r1 = null;
7342                    globalThis.r2 = null;
7343                    pi.ui("set_status", { text: "Working..." })
7344                        .then((r) => { globalThis.r1 = r; });
7345                    pi.ui("set_widget", { lines: ["Line 1", "Line 2"] })
7346                        .then((r) => { globalThis.r2 = r; });
7347                "#,
7348                )
7349                .await
7350                .expect("eval");
7351
7352            let requests = runtime.drain_hostcall_requests();
7353            assert_eq!(requests.len(), 2);
7354
7355            let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
7356            let ui_handler = Arc::new(TestUiHandler {
7357                captured: Arc::clone(&captured),
7358                response_value: Value::Null,
7359            });
7360
7361            let dispatcher = ExtensionDispatcher::new(
7362                Rc::clone(&runtime),
7363                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
7364                Arc::new(HttpConnector::with_defaults()),
7365                Arc::new(NullSession),
7366                ui_handler,
7367                PathBuf::from("."),
7368            );
7369
7370            for request in requests {
7371                dispatcher.dispatch_and_complete(request).await;
7372            }
7373
7374            while runtime.has_pending() {
7375                runtime.tick().await.expect("tick");
7376                runtime.drain_microtasks().await.expect("microtasks");
7377            }
7378
7379            let (len, methods) = {
7380                let reqs = captured.lock().unwrap();
7381                let len = reqs.len();
7382                let methods = reqs.iter().map(|r| r.method.clone()).collect::<Vec<_>>();
7383                drop(reqs);
7384                (len, methods)
7385            };
7386            assert_eq!(len, 2);
7387            assert!(methods.iter().any(|method| method == "set_status"));
7388            assert!(methods.iter().any(|method| method == "set_widget"));
7389        });
7390    }
7391
7392    // ---- Exec edge case tests ----
7393
7394    #[test]
7395    fn dispatcher_exec_with_custom_cwd() {
7396        futures::executor::block_on(async {
7397            let runtime = Rc::new(
7398                PiJsRuntime::with_clock(DeterministicClock::new(0))
7399                    .await
7400                    .expect("runtime"),
7401            );
7402
7403            runtime
7404                .eval(
7405                    r#"
7406                    globalThis.result = null;
7407                    pi.exec("pwd", { cwd: "/tmp" })
7408                        .then((r) => { globalThis.result = r; })
7409                        .catch((e) => { globalThis.result = { error: e.message || String(e) }; });
7410                "#,
7411                )
7412                .await
7413                .expect("eval");
7414
7415            let requests = runtime.drain_hostcall_requests();
7416            assert_eq!(requests.len(), 1);
7417
7418            let dispatcher = build_dispatcher(Rc::clone(&runtime));
7419            for request in requests {
7420                dispatcher.dispatch_and_complete(request).await;
7421            }
7422
7423            while runtime.has_pending() {
7424                runtime.tick().await.expect("tick");
7425                runtime.drain_microtasks().await.expect("microtasks");
7426            }
7427
7428            runtime
7429                .eval(
7430                    r#"
7431                    if (!globalThis.result) throw new Error("exec not resolved");
7432                    // Either it resolved to stdout containing /tmp, or it
7433                    // was rejected - both are valid dispatcher behaviors.
7434                    // Key assertion: the dispatcher didn't panic.
7435                "#,
7436                )
7437                .await
7438                .expect("verify exec cwd");
7439        });
7440    }
7441
7442    #[test]
7443    fn dispatcher_exec_empty_command_rejects() {
7444        futures::executor::block_on(async {
7445            let runtime = Rc::new(
7446                PiJsRuntime::with_clock(DeterministicClock::new(0))
7447                    .await
7448                    .expect("runtime"),
7449            );
7450
7451            runtime
7452                .eval(
7453                    r#"
7454                    globalThis.errMsg = "";
7455                    pi.exec("")
7456                        .then(() => { globalThis.errMsg = "should_not_resolve"; })
7457                        .catch((e) => { globalThis.errMsg = e.message || String(e); });
7458                "#,
7459                )
7460                .await
7461                .expect("eval");
7462
7463            let requests = runtime.drain_hostcall_requests();
7464            assert_eq!(requests.len(), 1);
7465
7466            let dispatcher = build_dispatcher(Rc::clone(&runtime));
7467            for request in requests {
7468                dispatcher.dispatch_and_complete(request).await;
7469            }
7470
7471            while runtime.has_pending() {
7472                runtime.tick().await.expect("tick");
7473                runtime.drain_microtasks().await.expect("microtasks");
7474            }
7475
7476            runtime
7477                .eval(
7478                    r#"
7479                    if (globalThis.errMsg === "should_not_resolve") {
7480                        throw new Error("Expected rejection for empty command");
7481                    }
7482                    // Empty command should produce some kind of error
7483                    if (!globalThis.errMsg) {
7484                        throw new Error("Expected error message");
7485                    }
7486                "#,
7487                )
7488                .await
7489                .expect("verify empty command rejection");
7490        });
7491    }
7492
7493    // ---- Events edge case tests ----
7494
7495    #[test]
7496    fn dispatcher_events_emit_missing_event_name_rejects() {
7497        futures::executor::block_on(async {
7498            let runtime = Rc::new(
7499                PiJsRuntime::with_clock(DeterministicClock::new(0))
7500                    .await
7501                    .expect("runtime"),
7502            );
7503
7504            runtime
7505                .eval(
7506                    r#"
7507                    globalThis.errMsg = "";
7508                    pi.events("emit", {})
7509                        .then(() => { globalThis.errMsg = "should_not_resolve"; })
7510                        .catch((e) => { globalThis.errMsg = e.message || String(e); });
7511                "#,
7512                )
7513                .await
7514                .expect("eval");
7515
7516            let requests = runtime.drain_hostcall_requests();
7517            assert_eq!(requests.len(), 1);
7518
7519            let dispatcher = build_dispatcher(Rc::clone(&runtime));
7520            for request in requests {
7521                dispatcher.dispatch_and_complete(request).await;
7522            }
7523
7524            while runtime.has_pending() {
7525                runtime.tick().await.expect("tick");
7526                runtime.drain_microtasks().await.expect("microtasks");
7527            }
7528
7529            runtime
7530                .eval(
7531                    r#"
7532                    // Should either reject or produce an error - not silently succeed
7533                    if (globalThis.errMsg === "should_not_resolve") {
7534                        // It's also acceptable if emit with empty payload succeeds gracefully
7535                    }
7536                "#,
7537                )
7538                .await
7539                .expect("verify events emit");
7540        });
7541    }
7542
7543    #[test]
7544    fn dispatcher_events_list_empty_when_no_hooks() {
7545        futures::executor::block_on(async {
7546            let runtime = Rc::new(
7547                PiJsRuntime::with_clock(DeterministicClock::new(0))
7548                    .await
7549                    .expect("runtime"),
7550            );
7551
7552            // Register an extension with no hooks, then list events
7553            runtime
7554                .eval(
7555                    r#"
7556                    globalThis.result = null;
7557                    __pi_begin_extension("ext.empty", { name: "ext.empty" });
7558                    pi.events("list", {})
7559                        .then((r) => { globalThis.result = r; })
7560                        .catch((e) => { globalThis.result = { error: e.message || String(e) }; });
7561                    __pi_end_extension();
7562                "#,
7563                )
7564                .await
7565                .expect("eval");
7566
7567            let requests = runtime.drain_hostcall_requests();
7568            assert_eq!(requests.len(), 1);
7569
7570            let dispatcher = build_dispatcher(Rc::clone(&runtime));
7571            for request in requests {
7572                dispatcher.dispatch_and_complete(request).await;
7573            }
7574
7575            while runtime.has_pending() {
7576                runtime.tick().await.expect("tick");
7577                runtime.drain_microtasks().await.expect("microtasks");
7578            }
7579
7580            runtime
7581                .eval(
7582                    r#"
7583                    if (!globalThis.result) throw new Error("events list not resolved");
7584                    // Result is { events: [...] }
7585                    const events = globalThis.result.events;
7586                    if (!Array.isArray(events)) {
7587                        throw new Error("Expected events array, got: " + JSON.stringify(globalThis.result));
7588                    }
7589                    if (events.length !== 0) {
7590                        throw new Error("Expected empty events list, got: " + JSON.stringify(events));
7591                    }
7592                "#,
7593                )
7594                .await
7595                .expect("verify events list empty");
7596        });
7597    }
7598
7599    // ---- Isolated session op tests ----
7600
7601    #[test]
7602    fn dispatcher_session_get_file_isolated() {
7603        futures::executor::block_on(async {
7604            let runtime = Rc::new(
7605                PiJsRuntime::with_clock(DeterministicClock::new(0))
7606                    .await
7607                    .expect("runtime"),
7608            );
7609
7610            runtime
7611                .eval(
7612                    r#"
7613                    globalThis.file = "__unset__";
7614                    pi.session("get_file", {})
7615                        .then((r) => { globalThis.file = r; });
7616                "#,
7617                )
7618                .await
7619                .expect("eval");
7620
7621            let requests = runtime.drain_hostcall_requests();
7622            assert_eq!(requests.len(), 1);
7623
7624            let state = Arc::new(Mutex::new(serde_json::json!({
7625                "sessionFile": "/home/user/.pi/sessions/abc.json"
7626            })));
7627            let session = Arc::new(TestSession {
7628                state,
7629                messages: Arc::new(Mutex::new(Vec::new())),
7630                entries: Arc::new(Mutex::new(Vec::new())),
7631                branch: Arc::new(Mutex::new(Vec::new())),
7632                name: Arc::new(Mutex::new(None)),
7633                custom_entries: Arc::new(Mutex::new(Vec::new())),
7634                labels: Arc::new(Mutex::new(Vec::new())),
7635            });
7636
7637            let dispatcher = ExtensionDispatcher::new(
7638                Rc::clone(&runtime),
7639                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
7640                Arc::new(HttpConnector::with_defaults()),
7641                session,
7642                Arc::new(NullUiHandler),
7643                PathBuf::from("."),
7644            );
7645
7646            for request in requests {
7647                dispatcher.dispatch_and_complete(request).await;
7648            }
7649
7650            while runtime.has_pending() {
7651                runtime.tick().await.expect("tick");
7652                runtime.drain_microtasks().await.expect("microtasks");
7653            }
7654
7655            runtime
7656                .eval(
7657                    r#"
7658                    if (globalThis.file === "__unset__") throw new Error("get_file not resolved");
7659                    if (globalThis.file !== "/home/user/.pi/sessions/abc.json") {
7660                        throw new Error("Expected session file path, got: " + JSON.stringify(globalThis.file));
7661                    }
7662                "#,
7663                )
7664                .await
7665                .expect("verify get_file");
7666        });
7667    }
7668
7669    #[test]
7670    fn dispatcher_session_get_name_isolated() {
7671        futures::executor::block_on(async {
7672            let runtime = Rc::new(
7673                PiJsRuntime::with_clock(DeterministicClock::new(0))
7674                    .await
7675                    .expect("runtime"),
7676            );
7677
7678            runtime
7679                .eval(
7680                    r#"
7681                    globalThis.name = "__unset__";
7682                    pi.session("get_name", {})
7683                        .then((r) => { globalThis.name = r; });
7684                "#,
7685                )
7686                .await
7687                .expect("eval");
7688
7689            let requests = runtime.drain_hostcall_requests();
7690            assert_eq!(requests.len(), 1);
7691
7692            let state = Arc::new(Mutex::new(serde_json::json!({
7693                "sessionName": "My Debug Session"
7694            })));
7695            let session = Arc::new(TestSession {
7696                state,
7697                messages: Arc::new(Mutex::new(Vec::new())),
7698                entries: Arc::new(Mutex::new(Vec::new())),
7699                branch: Arc::new(Mutex::new(Vec::new())),
7700                name: Arc::new(Mutex::new(Some("My Debug Session".to_string()))),
7701                custom_entries: Arc::new(Mutex::new(Vec::new())),
7702                labels: Arc::new(Mutex::new(Vec::new())),
7703            });
7704
7705            let dispatcher = ExtensionDispatcher::new(
7706                Rc::clone(&runtime),
7707                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
7708                Arc::new(HttpConnector::with_defaults()),
7709                session,
7710                Arc::new(NullUiHandler),
7711                PathBuf::from("."),
7712            );
7713
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                    if (globalThis.name === "__unset__") throw new Error("get_name not resolved");
7727                    if (globalThis.name !== "My Debug Session") {
7728                        throw new Error("Expected session name, got: " + JSON.stringify(globalThis.name));
7729                    }
7730                "#,
7731                )
7732                .await
7733                .expect("verify get_name");
7734        });
7735    }
7736
7737    #[test]
7738    fn dispatcher_session_append_entry_custom_type_edge_cases() {
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            // Test with custom_type key (snake_case variant)
7747            runtime
7748                .eval(
7749                    r#"
7750                    globalThis.result = "__unset__";
7751                    pi.session("append_entry", {
7752                        custom_type: "audit_log",
7753                        data: { action: "login", ts: 1234567890 }
7754                    }).then((r) => { globalThis.result = r; });
7755                "#,
7756                )
7757                .await
7758                .expect("eval");
7759
7760            let requests = runtime.drain_hostcall_requests();
7761            assert_eq!(requests.len(), 1);
7762
7763            let custom_entries: CustomEntries = Arc::new(Mutex::new(Vec::new()));
7764            let session = Arc::new(TestSession {
7765                state: Arc::new(Mutex::new(serde_json::json!({}))),
7766                messages: Arc::new(Mutex::new(Vec::new())),
7767                entries: Arc::new(Mutex::new(Vec::new())),
7768                branch: Arc::new(Mutex::new(Vec::new())),
7769                name: Arc::new(Mutex::new(None)),
7770                custom_entries: Arc::clone(&custom_entries),
7771                labels: Arc::new(Mutex::new(Vec::new())),
7772            });
7773
7774            let dispatcher = ExtensionDispatcher::new(
7775                Rc::clone(&runtime),
7776                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
7777                Arc::new(HttpConnector::with_defaults()),
7778                session,
7779                Arc::new(NullUiHandler),
7780                PathBuf::from("."),
7781            );
7782
7783            for request in requests {
7784                dispatcher.dispatch_and_complete(request).await;
7785            }
7786
7787            while runtime.has_pending() {
7788                runtime.tick().await.expect("tick");
7789                runtime.drain_microtasks().await.expect("microtasks");
7790            }
7791
7792            let captured = custom_entries.lock().unwrap();
7793            assert_eq!(captured.len(), 1);
7794            assert_eq!(captured[0].0, "audit_log");
7795            assert!(captured[0].1.is_some());
7796            let data = captured[0].1.as_ref().unwrap().clone();
7797            drop(captured);
7798            assert_eq!(data["action"], "login");
7799        });
7800    }
7801
7802    #[test]
7803    fn dispatcher_events_emit_dispatches_custom_event() {
7804        futures::executor::block_on(async {
7805            let runtime = Rc::new(
7806                PiJsRuntime::with_clock(DeterministicClock::new(0))
7807                    .await
7808                    .expect("runtime"),
7809            );
7810
7811            runtime
7812                .eval(
7813                    r#"
7814                    globalThis.seen = [];
7815                    globalThis.emitResult = null;
7816
7817                    __pi_begin_extension("ext.b", { name: "ext.b" });
7818                    pi.on("custom_event", (payload, _ctx) => { globalThis.seen.push(payload); });
7819                    __pi_end_extension();
7820
7821                    __pi_begin_extension("ext.a", { name: "ext.a" });
7822                    pi.events("emit", { event: "custom_event", data: { hello: "world" } })
7823                      .then((r) => { globalThis.emitResult = r; });
7824                    __pi_end_extension();
7825                "#,
7826                )
7827                .await
7828                .expect("eval");
7829
7830            let requests = runtime.drain_hostcall_requests();
7831            assert_eq!(requests.len(), 1);
7832
7833            let dispatcher = build_dispatcher(Rc::clone(&runtime));
7834            for request in requests {
7835                dispatcher.dispatch_and_complete(request).await;
7836            }
7837
7838            runtime.tick().await.expect("tick");
7839
7840            runtime
7841                .eval(
7842                    r#"
7843                    if (!globalThis.emitResult) throw new Error("emit promise not resolved");
7844                    if (globalThis.emitResult.dispatched !== true) {
7845                        throw new Error("emit did not report dispatched: " + JSON.stringify(globalThis.emitResult));
7846                    }
7847                    if (globalThis.emitResult.event !== "custom_event") {
7848                        throw new Error("wrong event: " + JSON.stringify(globalThis.emitResult));
7849                    }
7850                    if (!Array.isArray(globalThis.seen) || globalThis.seen.length !== 1) {
7851                        throw new Error("event handler not called: " + JSON.stringify(globalThis.seen));
7852                    }
7853                    const payload = globalThis.seen[0];
7854                    if (!payload || payload.hello !== "world") {
7855                        throw new Error("wrong payload: " + JSON.stringify(payload));
7856                    }
7857                "#,
7858                )
7859                .await
7860                .expect("verify emit");
7861        });
7862    }
7863
7864    // ---- Additional exec conformance tests ----
7865    // These tests use Unix-specific commands (/bin/sh, /bin/echo) and are
7866    // skipped on Windows.
7867
7868    #[test]
7869    #[cfg(unix)]
7870    fn dispatcher_exec_with_args_array() {
7871        futures::executor::block_on(async {
7872            let runtime = Rc::new(
7873                PiJsRuntime::with_clock(DeterministicClock::new(0))
7874                    .await
7875                    .expect("runtime"),
7876            );
7877
7878            // pi.exec(cmd, args, options) - args is the second positional arg
7879            runtime
7880                .eval(
7881                    r#"
7882                    globalThis.result = null;
7883                    pi.exec("/bin/echo", ["hello", "world"], {})
7884                        .then((r) => { globalThis.result = r; })
7885                        .catch((e) => { globalThis.result = { error: e.message || String(e) }; });
7886                "#,
7887                )
7888                .await
7889                .expect("eval");
7890
7891            let requests = runtime.drain_hostcall_requests();
7892            assert_eq!(requests.len(), 1);
7893
7894            let dispatcher = build_dispatcher(Rc::clone(&runtime));
7895            for request in requests {
7896                dispatcher.dispatch_and_complete(request).await;
7897            }
7898
7899            while runtime.has_pending() {
7900                runtime.tick().await.expect("tick");
7901                runtime.drain_microtasks().await.expect("microtasks");
7902            }
7903
7904            runtime
7905                .eval(
7906                    r#"
7907                    if (!globalThis.result) throw new Error("exec not resolved");
7908                    if (globalThis.result.error) throw new Error("exec errored: " + globalThis.result.error);
7909                    if (typeof globalThis.result.stdout !== "string") {
7910                        throw new Error("Expected stdout string, got: " + JSON.stringify(globalThis.result));
7911                    }
7912                    if (!globalThis.result.stdout.includes("hello") || !globalThis.result.stdout.includes("world")) {
7913                        throw new Error("Expected 'hello world' in stdout, got: " + globalThis.result.stdout);
7914                    }
7915                "#,
7916                )
7917                .await
7918                .expect("verify exec with args");
7919        });
7920    }
7921
7922    #[test]
7923    #[cfg(unix)]
7924    fn dispatcher_exec_null_args_defaults_to_empty() {
7925        futures::executor::block_on(async {
7926            let runtime = Rc::new(
7927                PiJsRuntime::with_clock(DeterministicClock::new(0))
7928                    .await
7929                    .expect("runtime"),
7930            );
7931
7932            runtime
7933                .eval(
7934                    r#"
7935                    globalThis.result = null;
7936                    pi.exec("/bin/echo")
7937                        .then((r) => { globalThis.result = r; })
7938                        .catch((e) => { globalThis.result = { error: e.message || String(e) }; });
7939                "#,
7940                )
7941                .await
7942                .expect("eval");
7943
7944            let requests = runtime.drain_hostcall_requests();
7945            assert_eq!(requests.len(), 1);
7946
7947            let dispatcher = build_dispatcher(Rc::clone(&runtime));
7948            for request in requests {
7949                dispatcher.dispatch_and_complete(request).await;
7950            }
7951
7952            while runtime.has_pending() {
7953                runtime.tick().await.expect("tick");
7954                runtime.drain_microtasks().await.expect("microtasks");
7955            }
7956
7957            runtime
7958                .eval(
7959                    r#"
7960                    if (!globalThis.result) throw new Error("exec not resolved");
7961                    // echo with no args produces empty or newline stdout
7962                    if (globalThis.result.error) throw new Error("exec errored: " + globalThis.result.error);
7963                    if (typeof globalThis.result.stdout !== "string") {
7964                        throw new Error("Expected stdout string");
7965                    }
7966                "#,
7967                )
7968                .await
7969                .expect("verify exec null args");
7970        });
7971    }
7972
7973    #[test]
7974    fn dispatcher_exec_non_array_args_rejects() {
7975        futures::executor::block_on(async {
7976            let runtime = Rc::new(
7977                PiJsRuntime::with_clock(DeterministicClock::new(0))
7978                    .await
7979                    .expect("runtime"),
7980            );
7981
7982            runtime
7983                .eval(
7984                    r#"
7985                    globalThis.errMsg = "";
7986                    pi.exec("echo", "not-an-array", {})
7987                        .then(() => { globalThis.errMsg = "should_not_resolve"; })
7988                        .catch((e) => { globalThis.errMsg = e.message || String(e); });
7989                "#,
7990                )
7991                .await
7992                .expect("eval");
7993
7994            let requests = runtime.drain_hostcall_requests();
7995            assert_eq!(requests.len(), 1);
7996
7997            let dispatcher = build_dispatcher(Rc::clone(&runtime));
7998            for request in requests {
7999                dispatcher.dispatch_and_complete(request).await;
8000            }
8001
8002            while runtime.has_pending() {
8003                runtime.tick().await.expect("tick");
8004                runtime.drain_microtasks().await.expect("microtasks");
8005            }
8006
8007            runtime
8008                .eval(
8009                    r#"
8010                    if (globalThis.errMsg === "should_not_resolve") {
8011                        throw new Error("Expected rejection for non-array args");
8012                    }
8013                    if (!globalThis.errMsg.toLowerCase().includes("array")) {
8014                        throw new Error("Expected error about array, got: " + globalThis.errMsg);
8015                    }
8016                "#,
8017                )
8018                .await
8019                .expect("verify non-array args rejection");
8020        });
8021    }
8022
8023    #[test]
8024    #[cfg(unix)]
8025    fn dispatcher_exec_captures_stdout_and_stderr() {
8026        futures::executor::block_on(async {
8027            let runtime = Rc::new(
8028                PiJsRuntime::with_clock(DeterministicClock::new(0))
8029                    .await
8030                    .expect("runtime"),
8031            );
8032
8033            // Use sh -c to write to both stdout and stderr
8034            runtime
8035                .eval(
8036                    r#"
8037                    globalThis.result = null;
8038                    pi.exec("/bin/sh", ["-c", "echo OUT && echo ERR >&2"], {})
8039                        .then((r) => { globalThis.result = r; })
8040                        .catch((e) => { globalThis.result = { error: e.message || String(e) }; });
8041                "#,
8042                )
8043                .await
8044                .expect("eval");
8045
8046            let requests = runtime.drain_hostcall_requests();
8047            assert_eq!(requests.len(), 1);
8048
8049            let dispatcher = build_dispatcher(Rc::clone(&runtime));
8050            for request in requests {
8051                dispatcher.dispatch_and_complete(request).await;
8052            }
8053
8054            while runtime.has_pending() {
8055                runtime.tick().await.expect("tick");
8056                runtime.drain_microtasks().await.expect("microtasks");
8057            }
8058
8059            runtime
8060                .eval(
8061                    r#"
8062                    if (!globalThis.result) throw new Error("exec not resolved");
8063                    if (globalThis.result.error) throw new Error("exec errored: " + globalThis.result.error);
8064                    if (!globalThis.result.stdout.includes("OUT")) {
8065                        throw new Error("Expected 'OUT' in stdout, got: " + globalThis.result.stdout);
8066                    }
8067                    if (!globalThis.result.stderr.includes("ERR")) {
8068                        throw new Error("Expected 'ERR' in stderr, got: " + globalThis.result.stderr);
8069                    }
8070                "#,
8071                )
8072                .await
8073                .expect("verify stdout and stderr capture");
8074        });
8075    }
8076
8077    #[test]
8078    #[cfg(unix)]
8079    fn dispatcher_exec_nonzero_exit_code() {
8080        futures::executor::block_on(async {
8081            let runtime = Rc::new(
8082                PiJsRuntime::with_clock(DeterministicClock::new(0))
8083                    .await
8084                    .expect("runtime"),
8085            );
8086
8087            runtime
8088                .eval(
8089                    r#"
8090                    globalThis.result = null;
8091                    pi.exec("/bin/sh", ["-c", "exit 42"], {})
8092                        .then((r) => { globalThis.result = r; })
8093                        .catch((e) => { globalThis.result = { error: e.message || String(e) }; });
8094                "#,
8095                )
8096                .await
8097                .expect("eval");
8098
8099            let requests = runtime.drain_hostcall_requests();
8100            assert_eq!(requests.len(), 1);
8101
8102            let dispatcher = build_dispatcher(Rc::clone(&runtime));
8103            for request in requests {
8104                dispatcher.dispatch_and_complete(request).await;
8105            }
8106
8107            while runtime.has_pending() {
8108                runtime.tick().await.expect("tick");
8109                runtime.drain_microtasks().await.expect("microtasks");
8110            }
8111
8112            runtime
8113                .eval(
8114                    r#"
8115                    if (!globalThis.result) throw new Error("exec not resolved");
8116                    if (globalThis.result.error) throw new Error("exec errored: " + globalThis.result.error);
8117                    if (globalThis.result.code !== 42) {
8118                        throw new Error("Expected exit code 42, got: " + globalThis.result.code);
8119                    }
8120                "#,
8121                )
8122                .await
8123                .expect("verify nonzero exit code");
8124        });
8125    }
8126
8127    #[cfg(unix)]
8128    #[test]
8129    fn dispatcher_exec_signal_termination_reports_nonzero_code() {
8130        futures::executor::block_on(async {
8131            let runtime = Rc::new(
8132                PiJsRuntime::with_clock(DeterministicClock::new(0))
8133                    .await
8134                    .expect("runtime"),
8135            );
8136
8137            runtime
8138                .eval(
8139                    r#"
8140                    globalThis.result = null;
8141                    pi.exec("/bin/sh", ["-c", "kill -KILL $$"], {})
8142                        .then((r) => { globalThis.result = r; })
8143                        .catch((e) => { globalThis.result = { error: e.message || String(e) }; });
8144                "#,
8145                )
8146                .await
8147                .expect("eval");
8148
8149            let requests = runtime.drain_hostcall_requests();
8150            assert_eq!(requests.len(), 1);
8151
8152            let dispatcher = build_dispatcher(Rc::clone(&runtime));
8153            for request in requests {
8154                dispatcher.dispatch_and_complete(request).await;
8155            }
8156
8157            while runtime.has_pending() {
8158                runtime.tick().await.expect("tick");
8159                runtime.drain_microtasks().await.expect("microtasks");
8160            }
8161
8162            runtime
8163                .eval(
8164                    r#"
8165                    if (!globalThis.result) throw new Error("exec not resolved");
8166                    if (globalThis.result.error) throw new Error("exec errored: " + globalThis.result.error);
8167                    if (globalThis.result.code === 0) {
8168                        throw new Error("Expected non-zero exit code for signal termination, got: " + globalThis.result.code);
8169                    }
8170                "#,
8171                )
8172                .await
8173                .expect("verify signal termination exit code");
8174        });
8175    }
8176
8177    #[test]
8178    fn dispatcher_exec_command_not_found_rejects() {
8179        futures::executor::block_on(async {
8180            let runtime = Rc::new(
8181                PiJsRuntime::with_clock(DeterministicClock::new(0))
8182                    .await
8183                    .expect("runtime"),
8184            );
8185
8186            runtime
8187                .eval(
8188                    r#"
8189                    globalThis.errMsg = "";
8190                    pi.exec("__nonexistent_command_xyz__")
8191                        .then(() => { globalThis.errMsg = "should_not_resolve"; })
8192                        .catch((e) => { globalThis.errMsg = e.message || String(e); });
8193                "#,
8194                )
8195                .await
8196                .expect("eval");
8197
8198            let requests = runtime.drain_hostcall_requests();
8199            assert_eq!(requests.len(), 1);
8200
8201            let dispatcher = build_dispatcher(Rc::clone(&runtime));
8202            for request in requests {
8203                dispatcher.dispatch_and_complete(request).await;
8204            }
8205
8206            while runtime.has_pending() {
8207                runtime.tick().await.expect("tick");
8208                runtime.drain_microtasks().await.expect("microtasks");
8209            }
8210
8211            runtime
8212                .eval(
8213                    r#"
8214                    if (globalThis.errMsg === "should_not_resolve") {
8215                        throw new Error("Expected rejection for nonexistent command");
8216                    }
8217                    if (!globalThis.errMsg) {
8218                        throw new Error("Expected error message for nonexistent command");
8219                    }
8220                "#,
8221                )
8222                .await
8223                .expect("verify command not found rejection");
8224        });
8225    }
8226
8227    // ---- Additional HTTP conformance tests ----
8228
8229    #[test]
8230    fn dispatcher_http_tls_required_rejects_http_url() {
8231        futures::executor::block_on(async {
8232            let runtime = Rc::new(
8233                PiJsRuntime::with_clock(DeterministicClock::new(0))
8234                    .await
8235                    .expect("runtime"),
8236            );
8237
8238            runtime
8239                .eval(
8240                    r#"
8241                    globalThis.errMsg = "";
8242                    pi.http({ url: "http://example.com/test", method: "GET" })
8243                        .then(() => { globalThis.errMsg = "should_not_resolve"; })
8244                        .catch((e) => { globalThis.errMsg = e.message || String(e); });
8245                "#,
8246                )
8247                .await
8248                .expect("eval");
8249
8250            let requests = runtime.drain_hostcall_requests();
8251            assert_eq!(requests.len(), 1);
8252
8253            // Use default config which has require_tls: true
8254            let dispatcher = build_dispatcher(Rc::clone(&runtime));
8255            for request in requests {
8256                dispatcher.dispatch_and_complete(request).await;
8257            }
8258
8259            while runtime.has_pending() {
8260                runtime.tick().await.expect("tick");
8261                runtime.drain_microtasks().await.expect("microtasks");
8262            }
8263
8264            runtime
8265                .eval(
8266                    r#"
8267                    if (globalThis.errMsg === "should_not_resolve") {
8268                        throw new Error("Expected rejection for http:// URL when TLS required");
8269                    }
8270                    if (!globalThis.errMsg.toLowerCase().includes("tls") &&
8271                        !globalThis.errMsg.toLowerCase().includes("https")) {
8272                        throw new Error("Expected TLS-related error, got: " + globalThis.errMsg);
8273                    }
8274                "#,
8275                )
8276                .await
8277                .expect("verify TLS enforcement");
8278        });
8279    }
8280
8281    #[test]
8282    fn dispatcher_http_invalid_url_format_rejects() {
8283        futures::executor::block_on(async {
8284            let runtime = Rc::new(
8285                PiJsRuntime::with_clock(DeterministicClock::new(0))
8286                    .await
8287                    .expect("runtime"),
8288            );
8289
8290            runtime
8291                .eval(
8292                    r#"
8293                    globalThis.errMsg = "";
8294                    pi.http({ url: "not-a-valid-url", method: "GET" })
8295                        .then(() => { globalThis.errMsg = "should_not_resolve"; })
8296                        .catch((e) => { globalThis.errMsg = e.message || String(e); });
8297                "#,
8298                )
8299                .await
8300                .expect("eval");
8301
8302            let requests = runtime.drain_hostcall_requests();
8303            assert_eq!(requests.len(), 1);
8304
8305            let http_connector = HttpConnector::new(HttpConnectorConfig {
8306                require_tls: false,
8307                ..Default::default()
8308            });
8309            let dispatcher = ExtensionDispatcher::new(
8310                Rc::clone(&runtime),
8311                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
8312                Arc::new(http_connector),
8313                Arc::new(NullSession),
8314                Arc::new(NullUiHandler),
8315                PathBuf::from("."),
8316            );
8317
8318            for request in requests {
8319                dispatcher.dispatch_and_complete(request).await;
8320            }
8321
8322            while runtime.has_pending() {
8323                runtime.tick().await.expect("tick");
8324                runtime.drain_microtasks().await.expect("microtasks");
8325            }
8326
8327            runtime
8328                .eval(
8329                    r#"
8330                    if (globalThis.errMsg === "should_not_resolve") {
8331                        throw new Error("Expected rejection for invalid URL");
8332                    }
8333                    if (!globalThis.errMsg) {
8334                        throw new Error("Expected error message for invalid URL");
8335                    }
8336                "#,
8337                )
8338                .await
8339                .expect("verify invalid URL rejection");
8340        });
8341    }
8342
8343    #[test]
8344    fn dispatcher_http_get_with_body_rejects() {
8345        futures::executor::block_on(async {
8346            let runtime = Rc::new(
8347                PiJsRuntime::with_clock(DeterministicClock::new(0))
8348                    .await
8349                    .expect("runtime"),
8350            );
8351
8352            runtime
8353                .eval(
8354                    r#"
8355                    globalThis.errMsg = "";
8356                    pi.http({ url: "https://example.com/test", method: "GET", body: "should-not-have-body" })
8357                        .then(() => { globalThis.errMsg = "should_not_resolve"; })
8358                        .catch((e) => { globalThis.errMsg = e.message || String(e); });
8359                "#,
8360                )
8361                .await
8362                .expect("eval");
8363
8364            let requests = runtime.drain_hostcall_requests();
8365            assert_eq!(requests.len(), 1);
8366
8367            let dispatcher = build_dispatcher(Rc::clone(&runtime));
8368            for request in requests {
8369                dispatcher.dispatch_and_complete(request).await;
8370            }
8371
8372            while runtime.has_pending() {
8373                runtime.tick().await.expect("tick");
8374                runtime.drain_microtasks().await.expect("microtasks");
8375            }
8376
8377            runtime
8378                .eval(
8379                    r#"
8380                    if (globalThis.errMsg === "should_not_resolve") {
8381                        throw new Error("Expected rejection for GET with body");
8382                    }
8383                    if (!globalThis.errMsg.toLowerCase().includes("body") &&
8384                        !globalThis.errMsg.toLowerCase().includes("get")) {
8385                        throw new Error("Expected body/GET error, got: " + globalThis.errMsg);
8386                    }
8387                "#,
8388                )
8389                .await
8390                .expect("verify GET with body rejection");
8391        });
8392    }
8393
8394    #[test]
8395    fn dispatcher_http_response_body_returned() {
8396        futures::executor::block_on(async {
8397            let addr = spawn_http_server_with_status(200, "response-body-content");
8398            let url = format!("http://{addr}/body-test");
8399
8400            let runtime = Rc::new(
8401                PiJsRuntime::with_clock(DeterministicClock::new(0))
8402                    .await
8403                    .expect("runtime"),
8404            );
8405
8406            let script = format!(
8407                r#"
8408                globalThis.result = null;
8409                pi.http({{ url: "{url}", method: "GET" }})
8410                    .then((r) => {{ globalThis.result = r; }})
8411                    .catch((e) => {{ globalThis.result = {{ error: e.message || String(e) }}; }});
8412            "#
8413            );
8414            runtime.eval(&script).await.expect("eval");
8415
8416            let requests = runtime.drain_hostcall_requests();
8417            assert_eq!(requests.len(), 1);
8418
8419            let http_connector = HttpConnector::new(HttpConnectorConfig {
8420                require_tls: false,
8421                ..Default::default()
8422            });
8423            let dispatcher = ExtensionDispatcher::new(
8424                Rc::clone(&runtime),
8425                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
8426                Arc::new(http_connector),
8427                Arc::new(NullSession),
8428                Arc::new(NullUiHandler),
8429                PathBuf::from("."),
8430            );
8431
8432            for request in requests {
8433                dispatcher.dispatch_and_complete(request).await;
8434            }
8435
8436            while runtime.has_pending() {
8437                runtime.tick().await.expect("tick");
8438                runtime.drain_microtasks().await.expect("microtasks");
8439            }
8440
8441            runtime
8442                .eval(
8443                    r#"
8444                    if (!globalThis.result) throw new Error("HTTP not resolved");
8445                    if (globalThis.result.error) throw new Error("HTTP error: " + globalThis.result.error);
8446                    if (globalThis.result.status !== 200) {
8447                        throw new Error("Expected 200, got: " + globalThis.result.status);
8448                    }
8449                    const body = globalThis.result.body || "";
8450                    if (!body.includes("response-body-content")) {
8451                        throw new Error("Expected response body, got: " + body);
8452                    }
8453                "#,
8454                )
8455                .await
8456                .expect("verify response body");
8457        });
8458    }
8459
8460    #[test]
8461    fn dispatcher_http_error_status_code_returned() {
8462        futures::executor::block_on(async {
8463            let addr = spawn_http_server_with_status(404, "not found");
8464            let url = format!("http://{addr}/missing");
8465
8466            let runtime = Rc::new(
8467                PiJsRuntime::with_clock(DeterministicClock::new(0))
8468                    .await
8469                    .expect("runtime"),
8470            );
8471
8472            let script = format!(
8473                r#"
8474                globalThis.result = null;
8475                pi.http({{ url: "{url}", method: "GET" }})
8476                    .then((r) => {{ globalThis.result = r; }})
8477                    .catch((e) => {{ globalThis.result = {{ error: e.message || String(e) }}; }});
8478            "#
8479            );
8480            runtime.eval(&script).await.expect("eval");
8481
8482            let requests = runtime.drain_hostcall_requests();
8483            assert_eq!(requests.len(), 1);
8484
8485            let http_connector = HttpConnector::new(HttpConnectorConfig {
8486                require_tls: false,
8487                ..Default::default()
8488            });
8489            let dispatcher = ExtensionDispatcher::new(
8490                Rc::clone(&runtime),
8491                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
8492                Arc::new(http_connector),
8493                Arc::new(NullSession),
8494                Arc::new(NullUiHandler),
8495                PathBuf::from("."),
8496            );
8497
8498            for request in requests {
8499                dispatcher.dispatch_and_complete(request).await;
8500            }
8501
8502            while runtime.has_pending() {
8503                runtime.tick().await.expect("tick");
8504                runtime.drain_microtasks().await.expect("microtasks");
8505            }
8506
8507            runtime
8508                .eval(
8509                    r#"
8510                    if (!globalThis.result) throw new Error("HTTP not resolved");
8511                    // 404 should still resolve (not reject) with the status code
8512                    if (globalThis.result.status !== 404) {
8513                        throw new Error("Expected status 404, got: " + JSON.stringify(globalThis.result));
8514                    }
8515                "#,
8516                )
8517                .await
8518                .expect("verify error status code");
8519        });
8520    }
8521
8522    #[test]
8523    fn dispatcher_http_unsupported_scheme_rejects() {
8524        futures::executor::block_on(async {
8525            let runtime = Rc::new(
8526                PiJsRuntime::with_clock(DeterministicClock::new(0))
8527                    .await
8528                    .expect("runtime"),
8529            );
8530
8531            runtime
8532                .eval(
8533                    r#"
8534                    globalThis.errMsg = "";
8535                    pi.http({ url: "ftp://example.com/file", method: "GET" })
8536                        .then(() => { globalThis.errMsg = "should_not_resolve"; })
8537                        .catch((e) => { globalThis.errMsg = e.message || String(e); });
8538                "#,
8539                )
8540                .await
8541                .expect("eval");
8542
8543            let requests = runtime.drain_hostcall_requests();
8544            assert_eq!(requests.len(), 1);
8545
8546            let http_connector = HttpConnector::new(HttpConnectorConfig {
8547                require_tls: false,
8548                ..Default::default()
8549            });
8550            let dispatcher = ExtensionDispatcher::new(
8551                Rc::clone(&runtime),
8552                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
8553                Arc::new(http_connector),
8554                Arc::new(NullSession),
8555                Arc::new(NullUiHandler),
8556                PathBuf::from("."),
8557            );
8558
8559            for request in requests {
8560                dispatcher.dispatch_and_complete(request).await;
8561            }
8562
8563            while runtime.has_pending() {
8564                runtime.tick().await.expect("tick");
8565                runtime.drain_microtasks().await.expect("microtasks");
8566            }
8567
8568            runtime
8569                .eval(
8570                    r#"
8571                    if (globalThis.errMsg === "should_not_resolve") {
8572                        throw new Error("Expected rejection for ftp:// scheme");
8573                    }
8574                    if (!globalThis.errMsg.toLowerCase().includes("scheme") &&
8575                        !globalThis.errMsg.toLowerCase().includes("unsupported")) {
8576                        throw new Error("Expected scheme error, got: " + globalThis.errMsg);
8577                    }
8578                "#,
8579                )
8580                .await
8581                .expect("verify unsupported scheme rejection");
8582        });
8583    }
8584
8585    // ---- Additional UI conformance tests ----
8586
8587    #[test]
8588    fn dispatcher_ui_arbitrary_method_passthrough() {
8589        futures::executor::block_on(async {
8590            let runtime = Rc::new(
8591                PiJsRuntime::with_clock(DeterministicClock::new(0))
8592                    .await
8593                    .expect("runtime"),
8594            );
8595
8596            runtime
8597                .eval(
8598                    r#"
8599                    globalThis.result = null;
8600                    pi.ui("custom_op", { key: "value" })
8601                        .then((r) => { globalThis.result = r; });
8602                "#,
8603                )
8604                .await
8605                .expect("eval");
8606
8607            let requests = runtime.drain_hostcall_requests();
8608            assert_eq!(requests.len(), 1);
8609
8610            let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
8611            let ui_handler = Arc::new(TestUiHandler {
8612                captured: Arc::clone(&captured),
8613                response_value: Value::Null,
8614            });
8615
8616            let dispatcher = ExtensionDispatcher::new(
8617                Rc::clone(&runtime),
8618                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
8619                Arc::new(HttpConnector::with_defaults()),
8620                Arc::new(NullSession),
8621                ui_handler,
8622                PathBuf::from("."),
8623            );
8624
8625            for request in requests {
8626                dispatcher.dispatch_and_complete(request).await;
8627            }
8628
8629            while runtime.has_pending() {
8630                runtime.tick().await.expect("tick");
8631                runtime.drain_microtasks().await.expect("microtasks");
8632            }
8633
8634            let reqs = captured.lock().unwrap().clone();
8635            assert_eq!(reqs.len(), 1);
8636            assert_eq!(reqs[0].method, "custom_op");
8637            assert_eq!(reqs[0].payload["key"], "value");
8638        });
8639    }
8640
8641    #[test]
8642    fn dispatcher_ui_payload_passthrough_complex() {
8643        futures::executor::block_on(async {
8644            let runtime = Rc::new(
8645                PiJsRuntime::with_clock(DeterministicClock::new(0))
8646                    .await
8647                    .expect("runtime"),
8648            );
8649
8650            runtime
8651                .eval(
8652                    r#"
8653                    globalThis.result = null;
8654                    pi.ui("set_widget", {
8655                        lines: [
8656                            { text: "Line 1", style: { bold: true } },
8657                            { text: "Line 2", style: { color: "red" } }
8658                        ],
8659                        content: "widget body",
8660                        metadata: { nested: { deep: true } }
8661                    }).then((r) => { globalThis.result = r; });
8662                "#,
8663                )
8664                .await
8665                .expect("eval");
8666
8667            let requests = runtime.drain_hostcall_requests();
8668            assert_eq!(requests.len(), 1);
8669
8670            let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
8671            let ui_handler = Arc::new(TestUiHandler {
8672                captured: Arc::clone(&captured),
8673                response_value: Value::Null,
8674            });
8675
8676            let dispatcher = ExtensionDispatcher::new(
8677                Rc::clone(&runtime),
8678                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
8679                Arc::new(HttpConnector::with_defaults()),
8680                Arc::new(NullSession),
8681                ui_handler,
8682                PathBuf::from("."),
8683            );
8684
8685            for request in requests {
8686                dispatcher.dispatch_and_complete(request).await;
8687            }
8688
8689            while runtime.has_pending() {
8690                runtime.tick().await.expect("tick");
8691                runtime.drain_microtasks().await.expect("microtasks");
8692            }
8693
8694            let reqs = captured.lock().unwrap().clone();
8695            assert_eq!(reqs.len(), 1);
8696            let payload = &reqs[0].payload;
8697            assert!(payload["lines"].is_array());
8698            assert_eq!(payload["lines"].as_array().unwrap().len(), 2);
8699            assert_eq!(payload["content"], "widget body");
8700            assert_eq!(payload["metadata"]["nested"]["deep"], true);
8701        });
8702    }
8703
8704    #[test]
8705    fn dispatcher_ui_handler_returns_value() {
8706        futures::executor::block_on(async {
8707            let runtime = Rc::new(
8708                PiJsRuntime::with_clock(DeterministicClock::new(0))
8709                    .await
8710                    .expect("runtime"),
8711            );
8712
8713            runtime
8714                .eval(
8715                    r#"
8716                    globalThis.result = "__unset__";
8717                    pi.ui("get_input", { prompt: "Enter name" })
8718                        .then((r) => { globalThis.result = r; });
8719                "#,
8720                )
8721                .await
8722                .expect("eval");
8723
8724            let requests = runtime.drain_hostcall_requests();
8725            assert_eq!(requests.len(), 1);
8726
8727            let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
8728            let ui_handler = Arc::new(TestUiHandler {
8729                captured: Arc::clone(&captured),
8730                response_value: serde_json::json!({ "input": "Alice", "confirmed": true }),
8731            });
8732
8733            let dispatcher = ExtensionDispatcher::new(
8734                Rc::clone(&runtime),
8735                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
8736                Arc::new(HttpConnector::with_defaults()),
8737                Arc::new(NullSession),
8738                ui_handler,
8739                PathBuf::from("."),
8740            );
8741
8742            for request in requests {
8743                dispatcher.dispatch_and_complete(request).await;
8744            }
8745
8746            while runtime.has_pending() {
8747                runtime.tick().await.expect("tick");
8748                runtime.drain_microtasks().await.expect("microtasks");
8749            }
8750
8751            runtime
8752                .eval(
8753                    r#"
8754                    if (globalThis.result === "__unset__") throw new Error("UI not resolved");
8755                    if (globalThis.result.input !== "Alice") {
8756                        throw new Error("Expected input 'Alice', got: " + JSON.stringify(globalThis.result));
8757                    }
8758                    if (globalThis.result.confirmed !== true) {
8759                        throw new Error("Expected confirmed true");
8760                    }
8761                "#,
8762                )
8763                .await
8764                .expect("verify UI handler value");
8765        });
8766    }
8767
8768    #[test]
8769    fn dispatcher_ui_set_status_empty_text() {
8770        futures::executor::block_on(async {
8771            let runtime = Rc::new(
8772                PiJsRuntime::with_clock(DeterministicClock::new(0))
8773                    .await
8774                    .expect("runtime"),
8775            );
8776
8777            runtime
8778                .eval(
8779                    r#"
8780                    globalThis.result = null;
8781                    pi.ui("set_status", { text: "" })
8782                        .then((r) => { globalThis.result = r; });
8783                "#,
8784                )
8785                .await
8786                .expect("eval");
8787
8788            let requests = runtime.drain_hostcall_requests();
8789            assert_eq!(requests.len(), 1);
8790
8791            let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
8792            let ui_handler = Arc::new(TestUiHandler {
8793                captured: Arc::clone(&captured),
8794                response_value: Value::Null,
8795            });
8796
8797            let dispatcher = ExtensionDispatcher::new(
8798                Rc::clone(&runtime),
8799                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
8800                Arc::new(HttpConnector::with_defaults()),
8801                Arc::new(NullSession),
8802                ui_handler,
8803                PathBuf::from("."),
8804            );
8805
8806            for request in requests {
8807                dispatcher.dispatch_and_complete(request).await;
8808            }
8809
8810            while runtime.has_pending() {
8811                runtime.tick().await.expect("tick");
8812                runtime.drain_microtasks().await.expect("microtasks");
8813            }
8814
8815            let reqs = captured.lock().unwrap().clone();
8816            assert_eq!(reqs.len(), 1);
8817            assert_eq!(reqs[0].method, "set_status");
8818            assert_eq!(reqs[0].payload["text"], "");
8819        });
8820    }
8821
8822    #[test]
8823    fn dispatcher_ui_empty_payload() {
8824        futures::executor::block_on(async {
8825            let runtime = Rc::new(
8826                PiJsRuntime::with_clock(DeterministicClock::new(0))
8827                    .await
8828                    .expect("runtime"),
8829            );
8830
8831            runtime
8832                .eval(
8833                    r#"
8834                    globalThis.result = null;
8835                    pi.ui("dismiss", {})
8836                        .then((r) => { globalThis.result = r; });
8837                "#,
8838                )
8839                .await
8840                .expect("eval");
8841
8842            let requests = runtime.drain_hostcall_requests();
8843            assert_eq!(requests.len(), 1);
8844
8845            let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
8846            let ui_handler = Arc::new(TestUiHandler {
8847                captured: Arc::clone(&captured),
8848                response_value: Value::Null,
8849            });
8850
8851            let dispatcher = ExtensionDispatcher::new(
8852                Rc::clone(&runtime),
8853                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
8854                Arc::new(HttpConnector::with_defaults()),
8855                Arc::new(NullSession),
8856                ui_handler,
8857                PathBuf::from("."),
8858            );
8859
8860            for request in requests {
8861                dispatcher.dispatch_and_complete(request).await;
8862            }
8863
8864            while runtime.has_pending() {
8865                runtime.tick().await.expect("tick");
8866                runtime.drain_microtasks().await.expect("microtasks");
8867            }
8868
8869            let reqs = captured.lock().unwrap().clone();
8870            assert_eq!(reqs.len(), 1);
8871            assert_eq!(reqs[0].method, "dismiss");
8872        });
8873    }
8874
8875    #[test]
8876    fn dispatcher_ui_concurrent_different_methods() {
8877        futures::executor::block_on(async {
8878            let runtime = Rc::new(
8879                PiJsRuntime::with_clock(DeterministicClock::new(0))
8880                    .await
8881                    .expect("runtime"),
8882            );
8883
8884            runtime
8885                .eval(
8886                    r#"
8887                    globalThis.results = [];
8888                    pi.ui("set_status", { text: "Loading..." })
8889                        .then((r) => { globalThis.results.push("status"); });
8890                    pi.ui("show_spinner", { message: "Working" })
8891                        .then((r) => { globalThis.results.push("spinner"); });
8892                    pi.ui("set_widget", { lines: [], content: "w" })
8893                        .then((r) => { globalThis.results.push("widget"); });
8894                "#,
8895                )
8896                .await
8897                .expect("eval");
8898
8899            let requests = runtime.drain_hostcall_requests();
8900            assert_eq!(requests.len(), 3);
8901
8902            let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
8903            let ui_handler = Arc::new(TestUiHandler {
8904                captured: Arc::clone(&captured),
8905                response_value: Value::Null,
8906            });
8907
8908            let dispatcher = ExtensionDispatcher::new(
8909                Rc::clone(&runtime),
8910                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
8911                Arc::new(HttpConnector::with_defaults()),
8912                Arc::new(NullSession),
8913                ui_handler,
8914                PathBuf::from("."),
8915            );
8916
8917            for request in requests {
8918                dispatcher.dispatch_and_complete(request).await;
8919            }
8920
8921            while runtime.has_pending() {
8922                runtime.tick().await.expect("tick");
8923                runtime.drain_microtasks().await.expect("microtasks");
8924            }
8925
8926            let reqs = captured.lock().unwrap().clone();
8927            assert_eq!(reqs.len(), 3);
8928            let methods: Vec<&str> = reqs.iter().map(|r| r.method.as_str()).collect();
8929            assert!(methods.contains(&"set_status"));
8930            assert!(methods.contains(&"show_spinner"));
8931            assert!(methods.contains(&"set_widget"));
8932        });
8933    }
8934
8935    #[test]
8936    fn dispatcher_ui_notification_with_severity() {
8937        futures::executor::block_on(async {
8938            let runtime = Rc::new(
8939                PiJsRuntime::with_clock(DeterministicClock::new(0))
8940                    .await
8941                    .expect("runtime"),
8942            );
8943
8944            runtime
8945                .eval(
8946                    r#"
8947                    globalThis.result = null;
8948                    pi.ui("notification", { text: "Error occurred", severity: "error", duration: 5000 })
8949                        .then((r) => { globalThis.result = r; });
8950                "#,
8951                )
8952                .await
8953                .expect("eval");
8954
8955            let requests = runtime.drain_hostcall_requests();
8956            assert_eq!(requests.len(), 1);
8957
8958            let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
8959            let ui_handler = Arc::new(TestUiHandler {
8960                captured: Arc::clone(&captured),
8961                response_value: Value::Null,
8962            });
8963
8964            let dispatcher = ExtensionDispatcher::new(
8965                Rc::clone(&runtime),
8966                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
8967                Arc::new(HttpConnector::with_defaults()),
8968                Arc::new(NullSession),
8969                ui_handler,
8970                PathBuf::from("."),
8971            );
8972
8973            for request in requests {
8974                dispatcher.dispatch_and_complete(request).await;
8975            }
8976
8977            while runtime.has_pending() {
8978                runtime.tick().await.expect("tick");
8979                runtime.drain_microtasks().await.expect("microtasks");
8980            }
8981
8982            let reqs = captured.lock().unwrap().clone();
8983            assert_eq!(reqs.len(), 1);
8984            assert_eq!(reqs[0].method, "notification");
8985            assert_eq!(reqs[0].payload["severity"], "error");
8986            assert_eq!(reqs[0].payload["duration"], 5000);
8987        });
8988    }
8989
8990    #[test]
8991    fn dispatcher_ui_widget_with_lines_array() {
8992        futures::executor::block_on(async {
8993            let runtime = Rc::new(
8994                PiJsRuntime::with_clock(DeterministicClock::new(0))
8995                    .await
8996                    .expect("runtime"),
8997            );
8998
8999            runtime
9000                .eval(
9001                    r#"
9002                    globalThis.result = null;
9003                    pi.ui("set_widget", {
9004                        lines: [
9005                            { text: "=== Status ===" },
9006                            { text: "CPU: 42%" },
9007                            { text: "Mem: 8GB" }
9008                        ],
9009                        content: "Dashboard"
9010                    }).then((r) => { globalThis.result = r; });
9011                "#,
9012                )
9013                .await
9014                .expect("eval");
9015
9016            let requests = runtime.drain_hostcall_requests();
9017            assert_eq!(requests.len(), 1);
9018
9019            let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
9020            let ui_handler = Arc::new(TestUiHandler {
9021                captured: Arc::clone(&captured),
9022                response_value: Value::Null,
9023            });
9024
9025            let dispatcher = ExtensionDispatcher::new(
9026                Rc::clone(&runtime),
9027                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
9028                Arc::new(HttpConnector::with_defaults()),
9029                Arc::new(NullSession),
9030                ui_handler,
9031                PathBuf::from("."),
9032            );
9033
9034            for request in requests {
9035                dispatcher.dispatch_and_complete(request).await;
9036            }
9037
9038            while runtime.has_pending() {
9039                runtime.tick().await.expect("tick");
9040                runtime.drain_microtasks().await.expect("microtasks");
9041            }
9042
9043            let reqs = captured.lock().unwrap().clone();
9044            assert_eq!(reqs.len(), 1);
9045            assert_eq!(reqs[0].method, "set_widget");
9046            let lines = reqs[0].payload["lines"].as_array().unwrap();
9047            assert_eq!(lines.len(), 3);
9048            assert_eq!(lines[0]["text"], "=== Status ===");
9049            assert_eq!(lines[2]["text"], "Mem: 8GB");
9050        });
9051    }
9052
9053    #[test]
9054    fn dispatcher_ui_progress_with_percentage() {
9055        futures::executor::block_on(async {
9056            let runtime = Rc::new(
9057                PiJsRuntime::with_clock(DeterministicClock::new(0))
9058                    .await
9059                    .expect("runtime"),
9060            );
9061
9062            runtime
9063                .eval(
9064                    r#"
9065                    globalThis.result = null;
9066                    pi.ui("progress", { message: "Uploading", percent: 75, total: 100, current: 75 })
9067                        .then((r) => { globalThis.result = r; });
9068                "#,
9069                )
9070                .await
9071                .expect("eval");
9072
9073            let requests = runtime.drain_hostcall_requests();
9074            assert_eq!(requests.len(), 1);
9075
9076            let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
9077            let ui_handler = Arc::new(TestUiHandler {
9078                captured: Arc::clone(&captured),
9079                response_value: Value::Null,
9080            });
9081
9082            let dispatcher = ExtensionDispatcher::new(
9083                Rc::clone(&runtime),
9084                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
9085                Arc::new(HttpConnector::with_defaults()),
9086                Arc::new(NullSession),
9087                ui_handler,
9088                PathBuf::from("."),
9089            );
9090
9091            for request in requests {
9092                dispatcher.dispatch_and_complete(request).await;
9093            }
9094
9095            while runtime.has_pending() {
9096                runtime.tick().await.expect("tick");
9097                runtime.drain_microtasks().await.expect("microtasks");
9098            }
9099
9100            let reqs = captured.lock().unwrap().clone();
9101            assert_eq!(reqs.len(), 1);
9102            assert_eq!(reqs[0].method, "progress");
9103            assert_eq!(reqs[0].payload["percent"], 75);
9104            assert_eq!(reqs[0].payload["total"], 100);
9105            assert_eq!(reqs[0].payload["current"], 75);
9106        });
9107    }
9108
9109    // ---- Additional events conformance tests ----
9110
9111    #[test]
9112    fn dispatcher_events_emit_name_field_alias() {
9113        futures::executor::block_on(async {
9114            let runtime = Rc::new(
9115                PiJsRuntime::with_clock(DeterministicClock::new(0))
9116                    .await
9117                    .expect("runtime"),
9118            );
9119
9120            // Use "name" instead of "event" field
9121            runtime
9122                .eval(
9123                    r#"
9124                    globalThis.seen = [];
9125                    globalThis.emitResult = null;
9126
9127                    __pi_begin_extension("ext.listener", { name: "ext.listener" });
9128                    pi.on("named_event", (payload, _ctx) => { globalThis.seen.push(payload); });
9129                    __pi_end_extension();
9130
9131                    __pi_begin_extension("ext.emitter", { name: "ext.emitter" });
9132                    pi.events("emit", { name: "named_event", data: { via: "name_field" } })
9133                      .then((r) => { globalThis.emitResult = r; });
9134                    __pi_end_extension();
9135                "#,
9136                )
9137                .await
9138                .expect("eval");
9139
9140            let requests = runtime.drain_hostcall_requests();
9141            assert_eq!(requests.len(), 1);
9142
9143            let dispatcher = build_dispatcher(Rc::clone(&runtime));
9144            for request in requests {
9145                dispatcher.dispatch_and_complete(request).await;
9146            }
9147
9148            runtime.tick().await.expect("tick");
9149
9150            runtime
9151                .eval(
9152                    r#"
9153                    if (!globalThis.emitResult) throw new Error("emit not resolved");
9154                    if (globalThis.emitResult.dispatched !== true) {
9155                        throw new Error("emit not dispatched: " + JSON.stringify(globalThis.emitResult));
9156                    }
9157                    if (globalThis.seen.length !== 1) {
9158                        throw new Error("Expected 1 handler call, got: " + globalThis.seen.length);
9159                    }
9160                    if (globalThis.seen[0].via !== "name_field") {
9161                        throw new Error("Wrong payload: " + JSON.stringify(globalThis.seen[0]));
9162                    }
9163                "#,
9164                )
9165                .await
9166                .expect("verify name field alias");
9167        });
9168    }
9169
9170    #[test]
9171    fn dispatcher_events_unsupported_op_rejects() {
9172        futures::executor::block_on(async {
9173            let runtime = Rc::new(
9174                PiJsRuntime::with_clock(DeterministicClock::new(0))
9175                    .await
9176                    .expect("runtime"),
9177            );
9178
9179            runtime
9180                .eval(
9181                    r#"
9182                    globalThis.errMsg = "";
9183                    pi.events("nonexistent_op", {})
9184                        .then(() => { globalThis.errMsg = "should_not_resolve"; })
9185                        .catch((e) => { globalThis.errMsg = e.message || String(e); });
9186                "#,
9187                )
9188                .await
9189                .expect("eval");
9190
9191            let requests = runtime.drain_hostcall_requests();
9192            assert_eq!(requests.len(), 1);
9193
9194            let dispatcher = build_dispatcher(Rc::clone(&runtime));
9195            for request in requests {
9196                dispatcher.dispatch_and_complete(request).await;
9197            }
9198
9199            while runtime.has_pending() {
9200                runtime.tick().await.expect("tick");
9201                runtime.drain_microtasks().await.expect("microtasks");
9202            }
9203
9204            runtime
9205                .eval(
9206                    r#"
9207                    if (globalThis.errMsg === "should_not_resolve") {
9208                        throw new Error("Expected rejection for unsupported events op");
9209                    }
9210                    if (!globalThis.errMsg.toLowerCase().includes("unsupported")) {
9211                        throw new Error("Expected 'unsupported' error, got: " + globalThis.errMsg);
9212                    }
9213                "#,
9214                )
9215                .await
9216                .expect("verify unsupported op rejection");
9217        });
9218    }
9219
9220    #[test]
9221    fn dispatcher_events_emit_empty_event_name_rejects() {
9222        futures::executor::block_on(async {
9223            let runtime = Rc::new(
9224                PiJsRuntime::with_clock(DeterministicClock::new(0))
9225                    .await
9226                    .expect("runtime"),
9227            );
9228
9229            runtime
9230                .eval(
9231                    r#"
9232                    globalThis.errMsg = "";
9233                    pi.events("emit", { event: "" })
9234                        .then(() => { globalThis.errMsg = "should_not_resolve"; })
9235                        .catch((e) => { globalThis.errMsg = e.message || String(e); });
9236                "#,
9237                )
9238                .await
9239                .expect("eval");
9240
9241            let requests = runtime.drain_hostcall_requests();
9242            assert_eq!(requests.len(), 1);
9243
9244            let dispatcher = build_dispatcher(Rc::clone(&runtime));
9245            for request in requests {
9246                dispatcher.dispatch_and_complete(request).await;
9247            }
9248
9249            while runtime.has_pending() {
9250                runtime.tick().await.expect("tick");
9251                runtime.drain_microtasks().await.expect("microtasks");
9252            }
9253
9254            runtime
9255                .eval(
9256                    r#"
9257                    if (globalThis.errMsg === "should_not_resolve") {
9258                        throw new Error("Expected rejection for empty event name");
9259                    }
9260                    if (!globalThis.errMsg.includes("event") && !globalThis.errMsg.includes("non-empty")) {
9261                        throw new Error("Expected event name error, got: " + globalThis.errMsg);
9262                    }
9263                "#,
9264                )
9265                .await
9266                .expect("verify empty event name rejection");
9267        });
9268    }
9269
9270    #[test]
9271    fn dispatcher_events_emit_handler_count_in_response() {
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            // Register 2 handlers for same event
9280            runtime
9281                .eval(
9282                    r#"
9283                    globalThis.emitResult = null;
9284
9285                    __pi_begin_extension("ext.h1", { name: "ext.h1" });
9286                    pi.on("counted_event", (_p, _c) => {});
9287                    __pi_end_extension();
9288
9289                    __pi_begin_extension("ext.h2", { name: "ext.h2" });
9290                    pi.on("counted_event", (_p, _c) => {});
9291                    __pi_end_extension();
9292
9293                    __pi_begin_extension("ext.emitter", { name: "ext.emitter" });
9294                    pi.events("emit", { event: "counted_event", data: {} })
9295                      .then((r) => { globalThis.emitResult = r; });
9296                    __pi_end_extension();
9297                "#,
9298                )
9299                .await
9300                .expect("eval");
9301
9302            let requests = runtime.drain_hostcall_requests();
9303            assert_eq!(requests.len(), 1);
9304
9305            let dispatcher = build_dispatcher(Rc::clone(&runtime));
9306            for request in requests {
9307                dispatcher.dispatch_and_complete(request).await;
9308            }
9309
9310            runtime.tick().await.expect("tick");
9311
9312            runtime
9313                .eval(
9314                    r#"
9315                    if (!globalThis.emitResult) throw new Error("emit not resolved");
9316                    if (globalThis.emitResult.dispatched !== true) {
9317                        throw new Error("emit not dispatched: " + JSON.stringify(globalThis.emitResult));
9318                    }
9319                    if (typeof globalThis.emitResult.handler_count !== "number") {
9320                        throw new Error("Expected handler_count number, got: " + JSON.stringify(globalThis.emitResult));
9321                    }
9322                    if (globalThis.emitResult.handler_count < 2) {
9323                        throw new Error("Expected at least 2 handlers, got: " + globalThis.emitResult.handler_count);
9324                    }
9325                "#,
9326                )
9327                .await
9328                .expect("verify handler count");
9329        });
9330    }
9331
9332    #[test]
9333    fn dispatcher_events_list_returns_registered_event_names() {
9334        futures::executor::block_on(async {
9335            let runtime = Rc::new(
9336                PiJsRuntime::with_clock(DeterministicClock::new(0))
9337                    .await
9338                    .expect("runtime"),
9339            );
9340
9341            // Register multiple event hooks
9342            runtime
9343                .eval(
9344                    r#"
9345                    globalThis.result = null;
9346
9347                    __pi_begin_extension("ext.multi", { name: "ext.multi" });
9348                    pi.on("event_alpha", (_p, _c) => {});
9349                    pi.on("event_beta", (_p, _c) => {});
9350                    pi.on("event_gamma", (_p, _c) => {});
9351                    pi.events("list", {})
9352                        .then((r) => { globalThis.result = r; })
9353                        .catch((e) => { globalThis.result = { error: e.message || String(e) }; });
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            while runtime.has_pending() {
9369                runtime.tick().await.expect("tick");
9370                runtime.drain_microtasks().await.expect("microtasks");
9371            }
9372
9373            runtime
9374                .eval(
9375                    r#"
9376                    if (!globalThis.result) throw new Error("list not resolved");
9377                    if (globalThis.result.error) throw new Error("list error: " + globalThis.result.error);
9378                    const events = globalThis.result.events;
9379                    if (!Array.isArray(events)) {
9380                        throw new Error("Expected events array, got: " + JSON.stringify(globalThis.result));
9381                    }
9382                    if (events.length < 3) {
9383                        throw new Error("Expected at least 3 events, got: " + JSON.stringify(events));
9384                    }
9385                    if (!events.includes("event_alpha")) {
9386                        throw new Error("Missing event_alpha in: " + JSON.stringify(events));
9387                    }
9388                    if (!events.includes("event_beta")) {
9389                        throw new Error("Missing event_beta in: " + JSON.stringify(events));
9390                    }
9391                "#,
9392                )
9393                .await
9394                .expect("verify event names list");
9395        });
9396    }
9397
9398    #[test]
9399    fn dispatcher_events_emit_no_handlers_still_resolves() {
9400        futures::executor::block_on(async {
9401            let runtime = Rc::new(
9402                PiJsRuntime::with_clock(DeterministicClock::new(0))
9403                    .await
9404                    .expect("runtime"),
9405            );
9406
9407            // Emit an event that has no registered handlers
9408            runtime
9409                .eval(
9410                    r#"
9411                    globalThis.emitResult = null;
9412
9413                    __pi_begin_extension("ext.lonely", { name: "ext.lonely" });
9414                    pi.events("emit", { event: "unheard_event", data: { msg: "nobody listens" } })
9415                      .then((r) => { globalThis.emitResult = r; })
9416                      .catch((e) => { globalThis.emitResult = { error: e.message || String(e) }; });
9417                    __pi_end_extension();
9418                "#,
9419                )
9420                .await
9421                .expect("eval");
9422
9423            let requests = runtime.drain_hostcall_requests();
9424            assert_eq!(requests.len(), 1);
9425
9426            let dispatcher = build_dispatcher(Rc::clone(&runtime));
9427            for request in requests {
9428                dispatcher.dispatch_and_complete(request).await;
9429            }
9430
9431            while runtime.has_pending() {
9432                runtime.tick().await.expect("tick");
9433                runtime.drain_microtasks().await.expect("microtasks");
9434            }
9435
9436            runtime
9437                .eval(
9438                    r#"
9439                    if (!globalThis.emitResult) throw new Error("emit not resolved");
9440                    // Should resolve even with no handlers (dispatched: true, handler_count: 0)
9441                    if (globalThis.emitResult.error) {
9442                        throw new Error("emit errored: " + globalThis.emitResult.error);
9443                    }
9444                    if (globalThis.emitResult.dispatched !== true) {
9445                        throw new Error("emit not dispatched: " + JSON.stringify(globalThis.emitResult));
9446                    }
9447                "#,
9448                )
9449                .await
9450                .expect("verify emit with no handlers");
9451        });
9452    }
9453
9454    // ---- Additional tool conformance tests ----
9455
9456    #[test]
9457    fn dispatcher_tool_read_returns_file_content() {
9458        futures::executor::block_on(async {
9459            let temp_dir = tempfile::tempdir().expect("tempdir");
9460            let file_path = temp_dir.path().join("readable.txt");
9461            std::fs::write(&file_path, "file content here").expect("write test file");
9462
9463            let runtime = Rc::new(
9464                PiJsRuntime::with_clock(DeterministicClock::new(0))
9465                    .await
9466                    .expect("runtime"),
9467            );
9468
9469            let file_path_js = file_path.display().to_string().replace('\\', "\\\\");
9470            let script = format!(
9471                r#"
9472                globalThis.result = null;
9473                pi.tool("read", {{ path: "{file_path_js}" }})
9474                    .then((r) => {{ globalThis.result = r; }})
9475                    .catch((e) => {{ globalThis.result = {{ error: e.message || String(e) }}; }});
9476            "#
9477            );
9478            runtime.eval(&script).await.expect("eval");
9479
9480            let requests = runtime.drain_hostcall_requests();
9481            assert_eq!(requests.len(), 1);
9482
9483            let dispatcher = ExtensionDispatcher::new(
9484                Rc::clone(&runtime),
9485                Arc::new(ToolRegistry::new(&["read"], temp_dir.path(), None)),
9486                Arc::new(HttpConnector::with_defaults()),
9487                Arc::new(NullSession),
9488                Arc::new(NullUiHandler),
9489                temp_dir.path().to_path_buf(),
9490            );
9491
9492            for request in requests {
9493                dispatcher.dispatch_and_complete(request).await;
9494            }
9495
9496            while runtime.has_pending() {
9497                runtime.tick().await.expect("tick");
9498                runtime.drain_microtasks().await.expect("microtasks");
9499            }
9500
9501            runtime
9502                .eval(
9503                    r#"
9504                    if (!globalThis.result) throw new Error("read not resolved");
9505                    if (globalThis.result.error) throw new Error("read error: " + globalThis.result.error);
9506                "#,
9507                )
9508                .await
9509                .expect("verify read tool");
9510        });
9511    }
9512
9513    // ======================================================================
9514    // bd-321a.4: Session dispatcher taxonomy tests
9515    // ======================================================================
9516    // Table-driven tests proving dispatch_session returns taxonomy-correct
9517    // error codes (timeout|denied|io|invalid_request|internal).
9518
9519    /// Direct unit test of dispatch_session error taxonomy without JS runtime.
9520    /// Uses TestSession to verify error code classification for each operation.
9521    #[test]
9522    fn session_dispatch_taxonomy_unknown_op_is_invalid_request() {
9523        futures::executor::block_on(async {
9524            let runtime = Rc::new(
9525                PiJsRuntime::with_clock(DeterministicClock::new(0))
9526                    .await
9527                    .expect("runtime"),
9528            );
9529            let dispatcher = build_dispatcher(Rc::clone(&runtime));
9530            let outcome = dispatcher
9531                .dispatch_session("c1", "nonexistent_op", serde_json::json!({}))
9532                .await;
9533            match outcome {
9534                HostcallOutcome::Error { code, .. } => {
9535                    assert_eq!(
9536                        code, "invalid_request",
9537                        "unknown op must be invalid_request"
9538                    );
9539                }
9540                HostcallOutcome::Success(_) | HostcallOutcome::StreamChunk { .. } => {
9541                    panic!("unknown op should not succeed");
9542                }
9543            }
9544        });
9545    }
9546
9547    #[test]
9548    fn session_dispatch_taxonomy_set_model_missing_provider_is_invalid_request() {
9549        futures::executor::block_on(async {
9550            let runtime = Rc::new(
9551                PiJsRuntime::with_clock(DeterministicClock::new(0))
9552                    .await
9553                    .expect("runtime"),
9554            );
9555            let dispatcher = build_dispatcher(Rc::clone(&runtime));
9556            let outcome = dispatcher
9557                .dispatch_session("c2", "set_model", serde_json::json!({"modelId": "gpt-4o"}))
9558                .await;
9559            match outcome {
9560                HostcallOutcome::Error { code, .. } => {
9561                    assert_eq!(
9562                        code, "invalid_request",
9563                        "set_model missing provider must be invalid_request"
9564                    );
9565                }
9566                HostcallOutcome::Success(_) => {
9567                    panic!("set_model with missing provider should not succeed");
9568                }
9569                HostcallOutcome::StreamChunk { .. } => {
9570                    panic!("set_model with missing provider should not stream");
9571                }
9572            }
9573        });
9574    }
9575
9576    #[test]
9577    fn session_dispatch_taxonomy_set_model_missing_model_id_is_invalid_request() {
9578        futures::executor::block_on(async {
9579            let runtime = Rc::new(
9580                PiJsRuntime::with_clock(DeterministicClock::new(0))
9581                    .await
9582                    .expect("runtime"),
9583            );
9584            let dispatcher = build_dispatcher(Rc::clone(&runtime));
9585            let outcome = dispatcher
9586                .dispatch_session(
9587                    "c3",
9588                    "set_model",
9589                    serde_json::json!({"provider": "anthropic"}),
9590                )
9591                .await;
9592            match outcome {
9593                HostcallOutcome::Error { code, .. } => {
9594                    assert_eq!(code, "invalid_request");
9595                }
9596                HostcallOutcome::Success(_) => {
9597                    panic!("set_model with missing modelId should not succeed");
9598                }
9599                HostcallOutcome::StreamChunk { .. } => {
9600                    panic!("set_model with missing modelId should not stream");
9601                }
9602            }
9603        });
9604    }
9605
9606    #[test]
9607    fn session_dispatch_taxonomy_set_thinking_level_empty_is_invalid_request() {
9608        futures::executor::block_on(async {
9609            let runtime = Rc::new(
9610                PiJsRuntime::with_clock(DeterministicClock::new(0))
9611                    .await
9612                    .expect("runtime"),
9613            );
9614            let dispatcher = build_dispatcher(Rc::clone(&runtime));
9615            let outcome = dispatcher
9616                .dispatch_session("c4", "set_thinking_level", serde_json::json!({}))
9617                .await;
9618            match outcome {
9619                HostcallOutcome::Error { code, .. } => {
9620                    assert_eq!(code, "invalid_request");
9621                }
9622                HostcallOutcome::Success(_) => {
9623                    panic!("set_thinking_level with no level should not succeed");
9624                }
9625                HostcallOutcome::StreamChunk { .. } => {
9626                    panic!("set_thinking_level with no level should not stream");
9627                }
9628            }
9629        });
9630    }
9631
9632    #[test]
9633    fn session_dispatch_taxonomy_set_label_empty_target_is_invalid_request() {
9634        futures::executor::block_on(async {
9635            let runtime = Rc::new(
9636                PiJsRuntime::with_clock(DeterministicClock::new(0))
9637                    .await
9638                    .expect("runtime"),
9639            );
9640            let dispatcher = build_dispatcher(Rc::clone(&runtime));
9641            let outcome = dispatcher
9642                .dispatch_session("c5", "set_label", serde_json::json!({}))
9643                .await;
9644            match outcome {
9645                HostcallOutcome::Error { code, .. } => {
9646                    assert_eq!(code, "invalid_request");
9647                }
9648                HostcallOutcome::Success(_) => {
9649                    panic!("set_label with no targetId should not succeed");
9650                }
9651                HostcallOutcome::StreamChunk { .. } => {
9652                    panic!("set_label with no targetId should not stream");
9653                }
9654            }
9655        });
9656    }
9657
9658    #[test]
9659    fn session_dispatch_taxonomy_append_message_invalid_is_invalid_request() {
9660        futures::executor::block_on(async {
9661            let runtime = Rc::new(
9662                PiJsRuntime::with_clock(DeterministicClock::new(0))
9663                    .await
9664                    .expect("runtime"),
9665            );
9666            let dispatcher = build_dispatcher(Rc::clone(&runtime));
9667            let outcome = dispatcher
9668                .dispatch_session(
9669                    "c6",
9670                    "append_message",
9671                    serde_json::json!({"message": {"not_a_valid_message": true}}),
9672                )
9673                .await;
9674            match outcome {
9675                HostcallOutcome::Error { code, .. } => {
9676                    assert_eq!(
9677                        code, "invalid_request",
9678                        "malformed message must be invalid_request"
9679                    );
9680                }
9681                HostcallOutcome::Success(_) => {
9682                    panic!("invalid message should not succeed");
9683                }
9684                HostcallOutcome::StreamChunk { .. } => {
9685                    panic!("invalid message should not stream");
9686                }
9687            }
9688        });
9689    }
9690
9691    #[test]
9692    #[allow(clippy::items_after_statements, clippy::too_many_lines)]
9693    fn session_dispatch_taxonomy_io_error_from_session_trait() {
9694        futures::executor::block_on(async {
9695            let runtime = Rc::new(
9696                PiJsRuntime::with_clock(DeterministicClock::new(0))
9697                    .await
9698                    .expect("runtime"),
9699            );
9700
9701            // Use a session impl that returns IO errors
9702            struct FailSession;
9703
9704            #[async_trait]
9705            impl ExtensionSession for FailSession {
9706                async fn get_state(&self) -> Value {
9707                    Value::Null
9708                }
9709                async fn get_messages(&self) -> Vec<SessionMessage> {
9710                    Vec::new()
9711                }
9712                async fn get_entries(&self) -> Vec<Value> {
9713                    Vec::new()
9714                }
9715                async fn get_branch(&self) -> Vec<Value> {
9716                    Vec::new()
9717                }
9718                async fn set_name(&self, _name: String) -> Result<()> {
9719                    Err(crate::error::Error::from(std::io::Error::other(
9720                        "disk full",
9721                    )))
9722                }
9723                async fn append_message(&self, _message: SessionMessage) -> Result<()> {
9724                    Err(crate::error::Error::from(std::io::Error::other(
9725                        "disk full",
9726                    )))
9727                }
9728                async fn append_custom_entry(
9729                    &self,
9730                    _custom_type: String,
9731                    _data: Option<Value>,
9732                ) -> Result<()> {
9733                    Err(crate::error::Error::from(std::io::Error::other(
9734                        "disk full",
9735                    )))
9736                }
9737                async fn set_model(&self, _provider: String, _model_id: String) -> Result<()> {
9738                    Err(crate::error::Error::from(std::io::Error::other(
9739                        "disk full",
9740                    )))
9741                }
9742                async fn get_model(&self) -> (Option<String>, Option<String>) {
9743                    (None, None)
9744                }
9745                async fn set_thinking_level(&self, _level: String) -> Result<()> {
9746                    Err(crate::error::Error::from(std::io::Error::other(
9747                        "disk full",
9748                    )))
9749                }
9750                async fn get_thinking_level(&self) -> Option<String> {
9751                    None
9752                }
9753                async fn set_label(
9754                    &self,
9755                    _target_id: String,
9756                    _label: Option<String>,
9757                ) -> Result<()> {
9758                    Err(crate::error::Error::from(std::io::Error::other(
9759                        "disk full",
9760                    )))
9761                }
9762            }
9763
9764            let dispatcher = ExtensionDispatcher::new(
9765                Rc::clone(&runtime),
9766                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
9767                Arc::new(HttpConnector::with_defaults()),
9768                Arc::new(FailSession),
9769                Arc::new(NullUiHandler),
9770                PathBuf::from("."),
9771            );
9772
9773            // Table of ops that call session trait mutators (which will fail with IO error)
9774            let io_cases = [
9775                ("set_name", serde_json::json!({"name": "test"})),
9776                (
9777                    "set_model",
9778                    serde_json::json!({"provider": "a", "modelId": "b"}),
9779                ),
9780                ("set_thinking_level", serde_json::json!({"level": "high"})),
9781                (
9782                    "set_label",
9783                    serde_json::json!({"targetId": "abc", "label": "x"}),
9784                ),
9785                (
9786                    "append_entry",
9787                    serde_json::json!({"customType": "note", "data": null}),
9788                ),
9789                (
9790                    "append_message",
9791                    serde_json::json!({"message": {"role": "custom", "customType": "x", "content": "y", "display": true}}),
9792                ),
9793            ];
9794
9795            for (op, params) in &io_cases {
9796                let outcome = dispatcher.dispatch_session("cx", op, params.clone()).await;
9797                match outcome {
9798                    HostcallOutcome::Error { code, .. } => {
9799                        assert_eq!(code, "io", "session IO error for op '{op}' must be 'io'");
9800                    }
9801                    HostcallOutcome::Success(_) => {
9802                        panic!("op '{op}' with failing session should not succeed");
9803                    }
9804                    HostcallOutcome::StreamChunk { .. } => {
9805                        panic!("op '{op}' with failing session should not stream");
9806                    }
9807                }
9808            }
9809        });
9810    }
9811
9812    #[test]
9813    fn session_dispatch_taxonomy_read_ops_succeed_with_null_session() {
9814        futures::executor::block_on(async {
9815            let runtime = Rc::new(
9816                PiJsRuntime::with_clock(DeterministicClock::new(0))
9817                    .await
9818                    .expect("runtime"),
9819            );
9820            let dispatcher = build_dispatcher(Rc::clone(&runtime));
9821
9822            let read_ops = [
9823                "get_state",
9824                "getState",
9825                "get_messages",
9826                "getMessages",
9827                "get_entries",
9828                "getEntries",
9829                "get_branch",
9830                "getBranch",
9831                "get_file",
9832                "getFile",
9833                "get_name",
9834                "getName",
9835                "get_model",
9836                "getModel",
9837                "get_thinking_level",
9838                "getThinkingLevel",
9839            ];
9840
9841            for op in &read_ops {
9842                let outcome = dispatcher
9843                    .dispatch_session("cr", op, serde_json::json!({}))
9844                    .await;
9845                assert!(
9846                    matches!(outcome, HostcallOutcome::Success(_)),
9847                    "read op '{op}' should succeed"
9848                );
9849            }
9850        });
9851    }
9852
9853    #[test]
9854    fn session_dispatch_taxonomy_case_insensitive_aliases() {
9855        futures::executor::block_on(async {
9856            let runtime = Rc::new(
9857                PiJsRuntime::with_clock(DeterministicClock::new(0))
9858                    .await
9859                    .expect("runtime"),
9860            );
9861            let dispatcher = build_dispatcher(Rc::clone(&runtime));
9862
9863            // Each alias pair should produce the same result
9864            let alias_pairs = [
9865                ("get_state", "getstate"),
9866                ("get_messages", "getmessages"),
9867                ("get_entries", "getentries"),
9868                ("get_branch", "getbranch"),
9869                ("get_file", "getfile"),
9870                ("get_name", "getname"),
9871                ("get_model", "getmodel"),
9872                ("get_thinking_level", "getthinkinglevel"),
9873            ];
9874
9875            for (snake, camel) in &alias_pairs {
9876                let outcome_a = dispatcher
9877                    .dispatch_session("ca", snake, serde_json::json!({}))
9878                    .await;
9879                let outcome_b = dispatcher
9880                    .dispatch_session("cb", camel, serde_json::json!({}))
9881                    .await;
9882                match (&outcome_a, &outcome_b) {
9883                    (HostcallOutcome::Success(a), HostcallOutcome::Success(b)) => {
9884                        assert_eq!(
9885                            a, b,
9886                            "alias pair ({snake}, {camel}) should produce same output"
9887                        );
9888                    }
9889                    _ => panic!("alias pair ({snake}, {camel}) should both succeed"),
9890                }
9891            }
9892        });
9893    }
9894
9895    #[test]
9896    fn ui_dispatch_taxonomy_missing_op_is_invalid_request() {
9897        futures::executor::block_on(async {
9898            let runtime = Rc::new(
9899                PiJsRuntime::with_clock(DeterministicClock::new(0))
9900                    .await
9901                    .expect("runtime"),
9902            );
9903            let dispatcher = build_dispatcher(Rc::clone(&runtime));
9904            let outcome = dispatcher
9905                .dispatch_ui("ui-1", "   ", serde_json::json!({}), None)
9906                .await;
9907            assert!(
9908                matches!(outcome, HostcallOutcome::Error { code, .. } if code == "invalid_request")
9909            );
9910        });
9911    }
9912
9913    #[test]
9914    fn ui_dispatch_taxonomy_timeout_error_maps_to_timeout() {
9915        futures::executor::block_on(async {
9916            struct TimeoutUiHandler;
9917
9918            #[async_trait]
9919            impl ExtensionUiHandler for TimeoutUiHandler {
9920                async fn request_ui(
9921                    &self,
9922                    _request: ExtensionUiRequest,
9923                ) -> Result<Option<ExtensionUiResponse>> {
9924                    Err(Error::extension("Extension UI request timed out"))
9925                }
9926            }
9927
9928            let runtime = Rc::new(
9929                PiJsRuntime::with_clock(DeterministicClock::new(0))
9930                    .await
9931                    .expect("runtime"),
9932            );
9933            let dispatcher = ExtensionDispatcher::new(
9934                Rc::clone(&runtime),
9935                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
9936                Arc::new(HttpConnector::with_defaults()),
9937                Arc::new(NullSession),
9938                Arc::new(TimeoutUiHandler),
9939                PathBuf::from("."),
9940            );
9941
9942            let outcome = dispatcher
9943                .dispatch_ui("ui-2", "confirm", serde_json::json!({}), None)
9944                .await;
9945            assert!(matches!(outcome, HostcallOutcome::Error { code, .. } if code == "timeout"));
9946        });
9947    }
9948
9949    #[test]
9950    fn ui_dispatch_taxonomy_unconfigured_maps_to_denied() {
9951        futures::executor::block_on(async {
9952            struct MissingUiHandler;
9953
9954            #[async_trait]
9955            impl ExtensionUiHandler for MissingUiHandler {
9956                async fn request_ui(
9957                    &self,
9958                    _request: ExtensionUiRequest,
9959                ) -> Result<Option<ExtensionUiResponse>> {
9960                    Err(Error::extension("Extension UI sender not configured"))
9961                }
9962            }
9963
9964            let runtime = Rc::new(
9965                PiJsRuntime::with_clock(DeterministicClock::new(0))
9966                    .await
9967                    .expect("runtime"),
9968            );
9969            let dispatcher = ExtensionDispatcher::new(
9970                Rc::clone(&runtime),
9971                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
9972                Arc::new(HttpConnector::with_defaults()),
9973                Arc::new(NullSession),
9974                Arc::new(MissingUiHandler),
9975                PathBuf::from("."),
9976            );
9977
9978            let outcome = dispatcher
9979                .dispatch_ui("ui-3", "confirm", serde_json::json!({}), None)
9980                .await;
9981            assert!(matches!(outcome, HostcallOutcome::Error { code, .. } if code == "denied"));
9982        });
9983    }
9984
9985    #[test]
9986    fn protocol_adapter_host_call_to_host_result_success() {
9987        futures::executor::block_on(async {
9988            let runtime = Rc::new(
9989                PiJsRuntime::with_clock(DeterministicClock::new(0))
9990                    .await
9991                    .expect("runtime"),
9992            );
9993            let dispatcher = build_dispatcher(Rc::clone(&runtime));
9994            let message = ExtensionMessage {
9995                id: "msg-hostcall-1".to_string(),
9996                version: PROTOCOL_VERSION.to_string(),
9997                body: ExtensionBody::HostCall(HostCallPayload {
9998                    call_id: "call-hostcall-1".to_string(),
9999                    capability: "session".to_string(),
10000                    method: "session".to_string(),
10001                    params: serde_json::json!({ "op": "get_state" }),
10002                    timeout_ms: None,
10003                    cancel_token: None,
10004                    context: None,
10005                }),
10006            };
10007
10008            let response = dispatcher
10009                .dispatch_protocol_message(message)
10010                .await
10011                .expect("protocol dispatch");
10012
10013            match response.body {
10014                ExtensionBody::HostResult(result) => {
10015                    assert_eq!(result.call_id, "call-hostcall-1");
10016                    assert!(!result.is_error, "expected success host_result");
10017                    assert!(
10018                        result.output.is_object(),
10019                        "host_result output must remain object"
10020                    );
10021                    assert!(result.error.is_none(), "success should not include error");
10022                }
10023                other => panic!("expected host_result body, got {other:?}"),
10024            }
10025        });
10026    }
10027
10028    #[test]
10029    fn protocol_adapter_missing_op_returns_invalid_request_taxonomy() {
10030        futures::executor::block_on(async {
10031            let runtime = Rc::new(
10032                PiJsRuntime::with_clock(DeterministicClock::new(0))
10033                    .await
10034                    .expect("runtime"),
10035            );
10036            let dispatcher = build_dispatcher(Rc::clone(&runtime));
10037            let message = ExtensionMessage {
10038                id: "msg-hostcall-2".to_string(),
10039                version: PROTOCOL_VERSION.to_string(),
10040                body: ExtensionBody::HostCall(HostCallPayload {
10041                    call_id: "call-hostcall-2".to_string(),
10042                    capability: "session".to_string(),
10043                    method: "session".to_string(),
10044                    params: serde_json::json!({}),
10045                    timeout_ms: None,
10046                    cancel_token: None,
10047                    context: None,
10048                }),
10049            };
10050
10051            let response = dispatcher
10052                .dispatch_protocol_message(message)
10053                .await
10054                .expect("protocol dispatch");
10055
10056            match response.body {
10057                ExtensionBody::HostResult(result) => {
10058                    assert!(result.is_error, "expected error host_result");
10059                    assert!(result.output.is_object(), "error output must be object");
10060                    let error = result.error.expect("error payload");
10061                    assert_eq!(
10062                        error.code,
10063                        crate::extensions::HostCallErrorCode::InvalidRequest
10064                    );
10065                    let details = error.details.expect("error details");
10066                    assert_eq!(
10067                        details["dispatcherDecisionTrace"]["selectedRuntime"],
10068                        Value::String("rust-extension-dispatcher".to_string())
10069                    );
10070                    assert_eq!(
10071                        details["dispatcherDecisionTrace"]["schemaPath"],
10072                        Value::String("ExtensionBody::HostCall/HostCallPayload".to_string())
10073                    );
10074                    assert_eq!(
10075                        details["dispatcherDecisionTrace"]["schemaVersion"],
10076                        Value::String(PROTOCOL_VERSION.to_string())
10077                    );
10078                    assert_eq!(
10079                        details["dispatcherDecisionTrace"]["fallbackReason"],
10080                        Value::String("schema_validation_failed".to_string())
10081                    );
10082                    assert_eq!(
10083                        details["extensionInput"]["method"],
10084                        Value::String("session".to_string())
10085                    );
10086                    assert_eq!(
10087                        details["extensionOutput"]["code"],
10088                        Value::String("invalid_request".to_string())
10089                    );
10090                }
10091                other => panic!("expected host_result body, got {other:?}"),
10092            }
10093        });
10094    }
10095
10096    #[test]
10097    fn protocol_adapter_unknown_method_includes_fallback_trace() {
10098        futures::executor::block_on(async {
10099            let runtime = Rc::new(
10100                PiJsRuntime::with_clock(DeterministicClock::new(0))
10101                    .await
10102                    .expect("runtime"),
10103            );
10104            let dispatcher = build_dispatcher(Rc::clone(&runtime));
10105            let message = ExtensionMessage {
10106                id: "msg-hostcall-unknown-method".to_string(),
10107                version: PROTOCOL_VERSION.to_string(),
10108                body: ExtensionBody::HostCall(HostCallPayload {
10109                    call_id: "call-hostcall-unknown-method".to_string(),
10110                    capability: "session".to_string(),
10111                    method: "not_a_real_method".to_string(),
10112                    params: serde_json::json!({ "foo": 1 }),
10113                    timeout_ms: None,
10114                    cancel_token: None,
10115                    context: None,
10116                }),
10117            };
10118
10119            let response = dispatcher
10120                .dispatch_protocol_message(message)
10121                .await
10122                .expect("protocol dispatch");
10123
10124            match response.body {
10125                ExtensionBody::HostResult(result) => {
10126                    assert!(result.is_error, "expected error host_result");
10127                    let error = result.error.expect("error payload");
10128                    assert_eq!(
10129                        error.code,
10130                        crate::extensions::HostCallErrorCode::InvalidRequest
10131                    );
10132                    let details = error.details.expect("error details");
10133                    assert_eq!(
10134                        details["dispatcherDecisionTrace"]["fallbackReason"],
10135                        Value::String("unsupported_method_fallback".to_string())
10136                    );
10137                    assert_eq!(
10138                        details["dispatcherDecisionTrace"]["method"],
10139                        Value::String("not_a_real_method".to_string())
10140                    );
10141                    assert_eq!(
10142                        details["schemaDiff"]["observedParamKeys"],
10143                        Value::Array(vec![Value::String("foo".to_string())])
10144                    );
10145                    assert_eq!(
10146                        details["extensionInput"]["params"]["foo"],
10147                        Value::Number(serde_json::Number::from(1))
10148                    );
10149                }
10150                other => panic!("expected host_result body, got {other:?}"),
10151            }
10152        });
10153    }
10154
10155    #[test]
10156    fn dispatch_events_list_unknown_extension_returns_empty_events() {
10157        futures::executor::block_on(async {
10158            let runtime = Rc::new(
10159                PiJsRuntime::with_clock(DeterministicClock::new(0))
10160                    .await
10161                    .expect("runtime"),
10162            );
10163            let dispatcher = build_dispatcher(Rc::clone(&runtime));
10164
10165            let outcome = dispatcher
10166                .dispatch_events(
10167                    "call-events-unknown-extension",
10168                    Some("missing.extension"),
10169                    "list",
10170                    serde_json::json!({}),
10171                )
10172                .await;
10173
10174            match outcome {
10175                HostcallOutcome::Success(value) => {
10176                    assert_eq!(value, serde_json::json!({ "events": [] }));
10177                }
10178                HostcallOutcome::Error { code, message } => {
10179                    panic!(
10180                        "events.list for unknown extension should not fail (code={code}): {message}"
10181                    );
10182                }
10183                HostcallOutcome::StreamChunk { .. } => {
10184                    panic!("events.list for unknown extension should not stream");
10185                }
10186            }
10187        });
10188    }
10189
10190    #[test]
10191    fn protocol_adapter_rejects_non_host_call_messages() {
10192        futures::executor::block_on(async {
10193            let runtime = Rc::new(
10194                PiJsRuntime::with_clock(DeterministicClock::new(0))
10195                    .await
10196                    .expect("runtime"),
10197            );
10198            let dispatcher = build_dispatcher(Rc::clone(&runtime));
10199            let message = ExtensionMessage {
10200                id: "msg-hostcall-3".to_string(),
10201                version: PROTOCOL_VERSION.to_string(),
10202                body: ExtensionBody::ToolResult(crate::extensions::ToolResultPayload {
10203                    call_id: "tool-1".to_string(),
10204                    output: serde_json::json!({}),
10205                    is_error: false,
10206                }),
10207            };
10208
10209            let err = dispatcher
10210                .dispatch_protocol_message(message)
10211                .await
10212                .expect_err("non-host-call should fail");
10213            assert!(
10214                err.to_string()
10215                    .contains("dispatch_protocol_message expects host_call"),
10216                "unexpected error: {err}"
10217            );
10218        });
10219    }
10220
10221    // -----------------------------------------------------------------------
10222    // Policy enforcement tests
10223    // -----------------------------------------------------------------------
10224
10225    #[test]
10226    fn dispatch_denied_capability_returns_error() {
10227        futures::executor::block_on(async {
10228            let runtime = Rc::new(
10229                PiJsRuntime::with_clock(DeterministicClock::new(0))
10230                    .await
10231                    .expect("runtime"),
10232            );
10233
10234            // Set up JS promise handler for pi.exec()
10235            runtime
10236                .eval(
10237                    r#"
10238                    globalThis.err = null;
10239                    pi.exec("echo", ["hello"]).catch((e) => { globalThis.err = e; });
10240                "#,
10241                )
10242                .await
10243                .expect("eval");
10244
10245            let requests = runtime.drain_hostcall_requests();
10246            assert_eq!(requests.len(), 1);
10247
10248            // Safe profile denies "exec"
10249            let policy = ExtensionPolicy::from_profile(PolicyProfile::Safe);
10250            let dispatcher = build_dispatcher_with_policy(Rc::clone(&runtime), policy);
10251
10252            for request in requests {
10253                dispatcher.dispatch_and_complete(request).await;
10254            }
10255
10256            let _ = runtime.tick().await.expect("tick");
10257
10258            runtime
10259                .eval(
10260                    r#"
10261                    if (globalThis.err === null) throw new Error("Promise not rejected");
10262                    if (globalThis.err.code !== "denied") {
10263                        throw new Error("Expected denied code, got: " + globalThis.err.code);
10264                    }
10265                "#,
10266                )
10267                .await
10268                .expect("verify denied error");
10269        });
10270    }
10271
10272    #[test]
10273    fn dispatch_denied_capability_still_denied_when_advanced_path_disabled() {
10274        futures::executor::block_on(async {
10275            let runtime = Rc::new(
10276                PiJsRuntime::with_clock(DeterministicClock::new(0))
10277                    .await
10278                    .expect("runtime"),
10279            );
10280
10281            runtime
10282                .eval(
10283                    r#"
10284                    globalThis.err = null;
10285                    pi.exec("echo", ["hello"]).catch((e) => { globalThis.err = e; });
10286                "#,
10287                )
10288                .await
10289                .expect("eval");
10290
10291            let requests = runtime.drain_hostcall_requests();
10292            assert_eq!(requests.len(), 1);
10293
10294            let oracle_config = DualExecOracleConfig {
10295                sample_ppm: 0,
10296                ..DualExecOracleConfig::default()
10297            };
10298            let policy = ExtensionPolicy::from_profile(PolicyProfile::Safe);
10299            let mut dispatcher =
10300                build_dispatcher_with_policy_and_oracle(Rc::clone(&runtime), policy, oracle_config);
10301            dispatcher.io_uring_lane_config = IoUringLanePolicyConfig::conservative();
10302            dispatcher.io_uring_force_compat = false;
10303            assert!(
10304                !dispatcher.advanced_dispatch_enabled(),
10305                "advanced path should be disabled for this test"
10306            );
10307
10308            for request in requests {
10309                dispatcher.dispatch_and_complete(request).await;
10310            }
10311
10312            let _ = runtime.tick().await.expect("tick");
10313
10314            runtime
10315                .eval(
10316                    r#"
10317                    if (globalThis.err === null) throw new Error("Promise not rejected");
10318                    if (globalThis.err.code !== "denied") {
10319                        throw new Error("Expected denied code, got: " + globalThis.err.code);
10320                    }
10321                "#,
10322                )
10323                .await
10324                .expect("verify denied error");
10325        });
10326    }
10327
10328    #[test]
10329    fn dispatch_allowed_capability_proceeds() {
10330        futures::executor::block_on(async {
10331            let runtime = Rc::new(
10332                PiJsRuntime::with_clock(DeterministicClock::new(0))
10333                    .await
10334                    .expect("runtime"),
10335            );
10336
10337            runtime
10338                .eval(
10339                    r#"
10340                    globalThis.result = null;
10341                    pi.log("test message").then((r) => { globalThis.result = r; });
10342                "#,
10343                )
10344                .await
10345                .expect("eval");
10346
10347            let requests = runtime.drain_hostcall_requests();
10348            assert_eq!(requests.len(), 1);
10349
10350            let policy = ExtensionPolicy::from_profile(PolicyProfile::Permissive);
10351            let dispatcher = build_dispatcher_with_policy(Rc::clone(&runtime), policy);
10352
10353            for request in requests {
10354                dispatcher.dispatch_and_complete(request).await;
10355            }
10356
10357            let _ = runtime.tick().await.expect("tick");
10358
10359            runtime
10360                .eval(
10361                    r#"
10362                    if (globalThis.result === null) throw new Error("Promise not resolved");
10363                "#,
10364                )
10365                .await
10366                .expect("verify allowed");
10367        });
10368    }
10369
10370    #[test]
10371    fn dispatch_allowed_capability_still_resolves_when_advanced_path_disabled() {
10372        futures::executor::block_on(async {
10373            let runtime = Rc::new(
10374                PiJsRuntime::with_clock(DeterministicClock::new(0))
10375                    .await
10376                    .expect("runtime"),
10377            );
10378
10379            runtime
10380                .eval(
10381                    r#"
10382                    globalThis.result = null;
10383                    pi.log("test message").then((r) => { globalThis.result = r; });
10384                "#,
10385                )
10386                .await
10387                .expect("eval");
10388
10389            let requests = runtime.drain_hostcall_requests();
10390            assert_eq!(requests.len(), 1);
10391
10392            let oracle_config = DualExecOracleConfig {
10393                sample_ppm: 0,
10394                ..DualExecOracleConfig::default()
10395            };
10396            let policy = ExtensionPolicy::from_profile(PolicyProfile::Permissive);
10397            let mut dispatcher =
10398                build_dispatcher_with_policy_and_oracle(Rc::clone(&runtime), policy, oracle_config);
10399            dispatcher.io_uring_lane_config = IoUringLanePolicyConfig::conservative();
10400            dispatcher.io_uring_force_compat = false;
10401            assert!(
10402                !dispatcher.advanced_dispatch_enabled(),
10403                "advanced path should be disabled for this test"
10404            );
10405
10406            for request in requests {
10407                dispatcher.dispatch_and_complete(request).await;
10408            }
10409
10410            let _ = runtime.tick().await.expect("tick");
10411
10412            runtime
10413                .eval(
10414                    r#"
10415                    if (globalThis.result === null) throw new Error("Promise not resolved");
10416                "#,
10417                )
10418                .await
10419                .expect("verify allowed");
10420        });
10421    }
10422
10423    #[test]
10424    fn advanced_dispatch_enabled_when_dual_exec_sampling_non_zero() {
10425        futures::executor::block_on(async {
10426            let runtime = Rc::new(
10427                PiJsRuntime::with_clock(DeterministicClock::new(0))
10428                    .await
10429                    .expect("runtime"),
10430            );
10431            let oracle_config = DualExecOracleConfig {
10432                sample_ppm: 1,
10433                ..DualExecOracleConfig::default()
10434            };
10435            let dispatcher = build_dispatcher_with_policy_and_oracle(
10436                Rc::clone(&runtime),
10437                ExtensionPolicy::from_profile(PolicyProfile::Permissive),
10438                oracle_config,
10439            );
10440            assert!(dispatcher.advanced_dispatch_enabled());
10441        });
10442    }
10443
10444    #[test]
10445    fn advanced_dispatch_enabled_when_io_uring_is_enabled() {
10446        futures::executor::block_on(async {
10447            let runtime = Rc::new(
10448                PiJsRuntime::with_clock(DeterministicClock::new(0))
10449                    .await
10450                    .expect("runtime"),
10451            );
10452            let oracle_config = DualExecOracleConfig {
10453                sample_ppm: 0,
10454                ..DualExecOracleConfig::default()
10455            };
10456            let mut dispatcher = build_dispatcher_with_policy_and_oracle(
10457                Rc::clone(&runtime),
10458                ExtensionPolicy::from_profile(PolicyProfile::Permissive),
10459                oracle_config,
10460            );
10461            dispatcher.io_uring_lane_config = IoUringLanePolicyConfig {
10462                enabled: true,
10463                ring_available: true,
10464                max_queue_depth: 256,
10465                allow_filesystem: true,
10466                allow_network: true,
10467            };
10468            assert!(dispatcher.advanced_dispatch_enabled());
10469        });
10470    }
10471
10472    #[test]
10473    fn advanced_dispatch_enabled_when_io_uring_force_compat_is_set() {
10474        futures::executor::block_on(async {
10475            let runtime = Rc::new(
10476                PiJsRuntime::with_clock(DeterministicClock::new(0))
10477                    .await
10478                    .expect("runtime"),
10479            );
10480            let oracle_config = DualExecOracleConfig {
10481                sample_ppm: 0,
10482                ..DualExecOracleConfig::default()
10483            };
10484            let mut dispatcher = build_dispatcher_with_policy_and_oracle(
10485                Rc::clone(&runtime),
10486                ExtensionPolicy::from_profile(PolicyProfile::Permissive),
10487                oracle_config,
10488            );
10489            dispatcher.io_uring_lane_config = IoUringLanePolicyConfig::conservative();
10490            dispatcher.io_uring_force_compat = true;
10491            assert!(dispatcher.advanced_dispatch_enabled());
10492        });
10493    }
10494
10495    #[test]
10496    fn dispatch_strict_mode_denies_unknown_capability() {
10497        futures::executor::block_on(async {
10498            let runtime = Rc::new(
10499                PiJsRuntime::with_clock(DeterministicClock::new(0))
10500                    .await
10501                    .expect("runtime"),
10502            );
10503
10504            runtime
10505                .eval(
10506                    r#"
10507                    globalThis.err = null;
10508                    pi.http({ url: "http://localhost" }).catch((e) => { globalThis.err = e; });
10509                "#,
10510                )
10511                .await
10512                .expect("eval");
10513
10514            let requests = runtime.drain_hostcall_requests();
10515            assert_eq!(requests.len(), 1);
10516
10517            // Strict mode with no default_caps: everything denied
10518            let policy = ExtensionPolicy {
10519                mode: ExtensionPolicyMode::Strict,
10520                max_memory_mb: 256,
10521                default_caps: Vec::new(),
10522                deny_caps: Vec::new(),
10523                per_extension: HashMap::new(),
10524                ..Default::default()
10525            };
10526            let dispatcher = build_dispatcher_with_policy(Rc::clone(&runtime), policy);
10527
10528            for request in requests {
10529                dispatcher.dispatch_and_complete(request).await;
10530            }
10531
10532            let _ = runtime.tick().await.expect("tick");
10533
10534            runtime
10535                .eval(
10536                    r#"
10537                    if (globalThis.err === null) throw new Error("Promise not rejected");
10538                    if (globalThis.err.code !== "denied") {
10539                        throw new Error("Expected denied code, got: " + globalThis.err.code);
10540                    }
10541                "#,
10542                )
10543                .await
10544                .expect("verify strict denied");
10545        });
10546    }
10547
10548    #[test]
10549    fn protocol_dispatch_denied_returns_error() {
10550        futures::executor::block_on(async {
10551            let runtime = Rc::new(
10552                PiJsRuntime::with_clock(DeterministicClock::new(0))
10553                    .await
10554                    .expect("runtime"),
10555            );
10556            // Safe profile denies "exec"
10557            let policy = ExtensionPolicy::from_profile(PolicyProfile::Safe);
10558            let dispatcher = build_dispatcher_with_policy(Rc::clone(&runtime), policy);
10559
10560            let message = ExtensionMessage {
10561                id: "msg-policy-deny".to_string(),
10562                version: PROTOCOL_VERSION.to_string(),
10563                body: ExtensionBody::HostCall(HostCallPayload {
10564                    call_id: "call-policy-deny".to_string(),
10565                    capability: "exec".to_string(),
10566                    method: "exec".to_string(),
10567                    params: serde_json::json!({ "cmd": "echo hello" }),
10568                    timeout_ms: None,
10569                    cancel_token: None,
10570                    context: None,
10571                }),
10572            };
10573
10574            let response = dispatcher
10575                .dispatch_protocol_message(message)
10576                .await
10577                .expect("protocol dispatch");
10578
10579            match response.body {
10580                ExtensionBody::HostResult(result) => {
10581                    assert!(result.is_error, "expected denied error result");
10582                    let error = result.error.expect("error payload");
10583                    assert_eq!(error.code, HostCallErrorCode::Denied);
10584                    assert!(
10585                        error.message.contains("exec"),
10586                        "error should mention denied capability: {}",
10587                        error.message
10588                    );
10589                }
10590                other => panic!("expected host_result body, got {other:?}"),
10591            }
10592        });
10593    }
10594
10595    #[test]
10596    fn dispatch_deny_caps_blocks_http() {
10597        futures::executor::block_on(async {
10598            let runtime = Rc::new(
10599                PiJsRuntime::with_clock(DeterministicClock::new(0))
10600                    .await
10601                    .expect("runtime"),
10602            );
10603
10604            runtime
10605                .eval(
10606                    r#"
10607                    globalThis.err = null;
10608                    pi.http({ url: "http://localhost" }).catch((e) => { globalThis.err = e; });
10609                "#,
10610                )
10611                .await
10612                .expect("eval");
10613
10614            let requests = runtime.drain_hostcall_requests();
10615            assert_eq!(requests.len(), 1);
10616
10617            let policy = ExtensionPolicy {
10618                mode: ExtensionPolicyMode::Permissive,
10619                max_memory_mb: 256,
10620                default_caps: Vec::new(),
10621                deny_caps: vec!["http".to_string()],
10622                per_extension: HashMap::new(),
10623                ..Default::default()
10624            };
10625            let dispatcher = build_dispatcher_with_policy(Rc::clone(&runtime), policy);
10626
10627            for request in requests {
10628                dispatcher.dispatch_and_complete(request).await;
10629            }
10630
10631            let _ = runtime.tick().await.expect("tick");
10632
10633            runtime
10634                .eval(
10635                    r#"
10636                    if (globalThis.err === null) throw new Error("Promise not rejected");
10637                    if (globalThis.err.code !== "denied") {
10638                        throw new Error("Expected denied code, got: " + globalThis.err.code);
10639                    }
10640                "#,
10641                )
10642                .await
10643                .expect("verify deny_caps http blocked");
10644        });
10645    }
10646
10647    #[test]
10648    fn per_extension_deny_blocks_specific_extension() {
10649        futures::executor::block_on(async {
10650            let runtime = Rc::new(
10651                PiJsRuntime::with_clock(DeterministicClock::new(0))
10652                    .await
10653                    .expect("runtime"),
10654            );
10655
10656            // Trigger a session hostcall from JS
10657            runtime
10658                .eval(
10659                    r#"
10660                    globalThis.err = null;
10661                    globalThis.result = null;
10662                    pi.session("getState", {}).catch((e) => { globalThis.err = e; })
10663                        .then((r) => { if (r) globalThis.result = r; });
10664                "#,
10665                )
10666                .await
10667                .expect("eval");
10668
10669            let requests = runtime.drain_hostcall_requests();
10670            assert_eq!(requests.len(), 1);
10671
10672            let mut per_extension = HashMap::new();
10673            per_extension.insert(
10674                "blocked-ext".to_string(),
10675                ExtensionOverride {
10676                    mode: None,
10677                    allow: Vec::new(),
10678                    deny: vec!["session".to_string()],
10679                    quota: None,
10680                },
10681            );
10682            let policy = ExtensionPolicy {
10683                mode: ExtensionPolicyMode::Permissive,
10684                max_memory_mb: 256,
10685                default_caps: Vec::new(),
10686                deny_caps: Vec::new(),
10687                per_extension,
10688                ..Default::default()
10689            };
10690            let dispatcher = build_dispatcher_with_policy(Rc::clone(&runtime), policy);
10691
10692            // Modify the request to come from the blocked extension
10693            let mut request = requests.into_iter().next().unwrap();
10694            request.extension_id = Some("blocked-ext".to_string());
10695
10696            dispatcher.dispatch_and_complete(request).await;
10697
10698            let _ = runtime.tick().await.expect("tick");
10699
10700            runtime
10701                .eval(
10702                    r#"
10703                    if (globalThis.err === null) throw new Error("Promise not rejected");
10704                    if (globalThis.err.code !== "denied") {
10705                        throw new Error("Expected denied code, got: " + globalThis.err.code);
10706                    }
10707                "#,
10708                )
10709                .await
10710                .expect("verify per-extension deny");
10711        });
10712    }
10713
10714    #[test]
10715    fn prompt_decision_treated_as_deny_in_dispatcher() {
10716        futures::executor::block_on(async {
10717            let runtime = Rc::new(
10718                PiJsRuntime::with_clock(DeterministicClock::new(0))
10719                    .await
10720                    .expect("runtime"),
10721            );
10722
10723            runtime
10724                .eval(
10725                    r#"
10726                    globalThis.err = null;
10727                    pi.exec("echo", ["hello"]).catch((e) => { globalThis.err = e; });
10728                "#,
10729                )
10730                .await
10731                .expect("eval");
10732
10733            let requests = runtime.drain_hostcall_requests();
10734            assert_eq!(requests.len(), 1);
10735
10736            // Prompt mode with no defaults → exec falls through to Prompt
10737            let policy = ExtensionPolicy {
10738                mode: ExtensionPolicyMode::Prompt,
10739                max_memory_mb: 256,
10740                default_caps: Vec::new(),
10741                deny_caps: Vec::new(),
10742                per_extension: HashMap::new(),
10743                ..Default::default()
10744            };
10745            let dispatcher = build_dispatcher_with_policy(Rc::clone(&runtime), policy);
10746
10747            for request in requests {
10748                dispatcher.dispatch_and_complete(request).await;
10749            }
10750
10751            let _ = runtime.tick().await.expect("tick");
10752
10753            runtime
10754                .eval(
10755                    r#"
10756                    if (globalThis.err === null) throw new Error("Promise not rejected");
10757                    if (globalThis.err.code !== "denied") {
10758                        throw new Error("Expected denied, got: " + globalThis.err.code);
10759                    }
10760                "#,
10761                )
10762                .await
10763                .expect("verify prompt treated as deny");
10764        });
10765    }
10766
10767    // -----------------------------------------------------------------------
10768    // Utility function unit tests
10769    // -----------------------------------------------------------------------
10770
10771    #[test]
10772    fn protocol_hostcall_op_extracts_op_field() {
10773        let params = serde_json::json!({ "op": "get_state" });
10774        assert_eq!(protocol_hostcall_op(&params), Some("get_state"));
10775    }
10776
10777    #[test]
10778    fn protocol_hostcall_op_extracts_method_field() {
10779        let params = serde_json::json!({ "method": "do_thing" });
10780        assert_eq!(protocol_hostcall_op(&params), Some("do_thing"));
10781    }
10782
10783    #[test]
10784    fn protocol_hostcall_op_extracts_name_field() {
10785        let params = serde_json::json!({ "name": "my_event" });
10786        assert_eq!(protocol_hostcall_op(&params), Some("my_event"));
10787    }
10788
10789    #[test]
10790    fn protocol_hostcall_op_prefers_op_over_method_and_name() {
10791        let params = serde_json::json!({ "op": "a", "method": "b", "name": "c" });
10792        assert_eq!(protocol_hostcall_op(&params), Some("a"));
10793    }
10794
10795    #[test]
10796    fn protocol_hostcall_op_falls_back_to_method_when_op_missing() {
10797        let params = serde_json::json!({ "method": "b", "name": "c" });
10798        assert_eq!(protocol_hostcall_op(&params), Some("b"));
10799    }
10800
10801    #[test]
10802    fn protocol_hostcall_op_returns_none_for_empty_or_whitespace() {
10803        assert_eq!(protocol_hostcall_op(&serde_json::json!({})), None);
10804        assert_eq!(protocol_hostcall_op(&serde_json::json!({ "op": "" })), None);
10805        assert_eq!(
10806            protocol_hostcall_op(&serde_json::json!({ "op": "   " })),
10807            None
10808        );
10809    }
10810
10811    #[test]
10812    fn protocol_hostcall_op_trims_whitespace() {
10813        let params = serde_json::json!({ "op": "  get_state  " });
10814        assert_eq!(protocol_hostcall_op(&params), Some("get_state"));
10815    }
10816
10817    #[test]
10818    fn protocol_hostcall_op_returns_none_for_non_string_values() {
10819        assert_eq!(protocol_hostcall_op(&serde_json::json!({ "op": 42 })), None);
10820        assert_eq!(
10821            protocol_hostcall_op(&serde_json::json!({ "op": true })),
10822            None
10823        );
10824        assert_eq!(
10825            protocol_hostcall_op(&serde_json::json!({ "op": null })),
10826            None
10827        );
10828    }
10829
10830    #[test]
10831    fn parse_protocol_hostcall_method_normalizes_case_and_whitespace() {
10832        assert!(matches!(
10833            parse_protocol_hostcall_method(" Tool "),
10834            Some(ProtocolHostcallMethod::Tool)
10835        ));
10836        assert!(matches!(
10837            parse_protocol_hostcall_method("EXEC"),
10838            Some(ProtocolHostcallMethod::Exec)
10839        ));
10840        assert!(matches!(
10841            parse_protocol_hostcall_method(" session "),
10842            Some(ProtocolHostcallMethod::Session)
10843        ));
10844    }
10845
10846    #[test]
10847    fn parse_protocol_hostcall_method_rejects_unknown_or_empty_values() {
10848        assert!(parse_protocol_hostcall_method("").is_none());
10849        assert!(parse_protocol_hostcall_method("   ").is_none());
10850        assert!(parse_protocol_hostcall_method("not_a_method").is_none());
10851    }
10852
10853    #[test]
10854    fn protocol_error_fallback_reason_preserves_invalid_request_taxonomy() {
10855        assert_eq!(
10856            protocol_error_fallback_reason("tool", "invalid_request"),
10857            "schema_validation_failed"
10858        );
10859        assert_eq!(
10860            protocol_error_fallback_reason("  SESSION ", "invalid_request"),
10861            "schema_validation_failed"
10862        );
10863        assert_eq!(
10864            protocol_error_fallback_reason("unknown", "invalid_request"),
10865            "unsupported_method_fallback"
10866        );
10867    }
10868
10869    #[test]
10870    fn protocol_error_fallback_reason_maps_non_invalid_request_codes() {
10871        assert_eq!(
10872            protocol_error_fallback_reason("tool", "denied"),
10873            "policy_denied"
10874        );
10875        assert_eq!(
10876            protocol_error_fallback_reason("tool", "timeout"),
10877            "handler_timeout"
10878        );
10879        assert_eq!(
10880            protocol_error_fallback_reason("tool", "tool_error"),
10881            "handler_error"
10882        );
10883        assert_eq!(
10884            protocol_error_fallback_reason("tool", "unexpected"),
10885            "runtime_internal_error"
10886        );
10887    }
10888
10889    #[test]
10890    fn protocol_normalize_output_passes_object_through() {
10891        let obj = serde_json::json!({ "key": "value" });
10892        assert_eq!(protocol_normalize_output(obj.clone()), obj);
10893    }
10894
10895    #[test]
10896    fn protocol_normalize_output_wraps_non_object_in_value_field() {
10897        assert_eq!(
10898            protocol_normalize_output(serde_json::json!("hello")),
10899            serde_json::json!({ "value": "hello" })
10900        );
10901        assert_eq!(
10902            protocol_normalize_output(serde_json::json!(42)),
10903            serde_json::json!({ "value": 42 })
10904        );
10905        assert_eq!(
10906            protocol_normalize_output(serde_json::json!(true)),
10907            serde_json::json!({ "value": true })
10908        );
10909        assert_eq!(
10910            protocol_normalize_output(Value::Null),
10911            serde_json::json!({ "value": null })
10912        );
10913        assert_eq!(
10914            protocol_normalize_output(serde_json::json!([1, 2, 3])),
10915            serde_json::json!({ "value": [1, 2, 3] })
10916        );
10917    }
10918
10919    #[test]
10920    fn protocol_error_code_maps_known_codes() {
10921        assert_eq!(protocol_error_code("timeout"), HostCallErrorCode::Timeout);
10922        assert_eq!(protocol_error_code("denied"), HostCallErrorCode::Denied);
10923        assert_eq!(protocol_error_code("io"), HostCallErrorCode::Io);
10924        assert_eq!(protocol_error_code("tool_error"), HostCallErrorCode::Io);
10925        assert_eq!(
10926            protocol_error_code("invalid_request"),
10927            HostCallErrorCode::InvalidRequest
10928        );
10929    }
10930
10931    #[test]
10932    fn protocol_error_code_unknown_maps_to_internal() {
10933        assert_eq!(
10934            protocol_error_code("something_else"),
10935            HostCallErrorCode::Internal
10936        );
10937        assert_eq!(protocol_error_code(""), HostCallErrorCode::Internal);
10938        assert_eq!(
10939            protocol_error_code("not_a_code"),
10940            HostCallErrorCode::Internal
10941        );
10942    }
10943
10944    #[test]
10945    fn protocol_error_code_normalizes_case_and_whitespace() {
10946        assert_eq!(protocol_error_code(" Timeout "), HostCallErrorCode::Timeout);
10947        assert_eq!(protocol_error_code("DENIED"), HostCallErrorCode::Denied);
10948        assert_eq!(protocol_error_code(" Tool_Error "), HostCallErrorCode::Io);
10949        assert_eq!(
10950            protocol_error_code(" Invalid_Request "),
10951            HostCallErrorCode::InvalidRequest
10952        );
10953    }
10954
10955    #[test]
10956    fn protocol_error_fallback_reason_normalizes_code_before_taxonomy_mapping() {
10957        assert_eq!(
10958            protocol_error_fallback_reason(" session ", " INVALID_REQUEST "),
10959            "schema_validation_failed"
10960        );
10961        assert_eq!(
10962            protocol_error_fallback_reason("unknown", " INVALID_REQUEST "),
10963            "unsupported_method_fallback"
10964        );
10965        assert_eq!(
10966            protocol_error_fallback_reason("tool", " TOOL_ERROR "),
10967            "handler_error"
10968        );
10969    }
10970
10971    fn test_protocol_payload(call_id: &str) -> HostCallPayload {
10972        HostCallPayload {
10973            call_id: call_id.to_string(),
10974            capability: "test".to_string(),
10975            method: "tool".to_string(),
10976            params: serde_json::json!({}),
10977            timeout_ms: None,
10978            cancel_token: None,
10979            context: None,
10980        }
10981    }
10982
10983    fn test_hostcall_request(call_id: &str, kind: HostcallKind, payload: Value) -> HostcallRequest {
10984        HostcallRequest {
10985            call_id: call_id.to_string(),
10986            kind,
10987            payload,
10988            trace_id: 0,
10989            extension_id: Some("ext.protocol.params".to_string()),
10990        }
10991    }
10992
10993    #[test]
10994    fn protocol_params_from_request_matches_hostcall_request_params_for_hash() {
10995        let requests = vec![
10996            test_hostcall_request(
10997                "tool-case",
10998                HostcallKind::Tool {
10999                    name: "read".to_string(),
11000                },
11001                serde_json::json!({ "path": "README.md" }),
11002            ),
11003            test_hostcall_request(
11004                "tool-non-object-case",
11005                HostcallKind::Tool {
11006                    name: "read".to_string(),
11007                },
11008                serde_json::json!(["README.md", "Cargo.toml"]),
11009            ),
11010            test_hostcall_request(
11011                "exec-object-case",
11012                HostcallKind::Exec {
11013                    cmd: "echo from kind".to_string(),
11014                },
11015                serde_json::json!({
11016                    "command": "legacy alias should be removed",
11017                    "cmd": "payload override should lose",
11018                    "args": ["hello"],
11019                }),
11020            ),
11021            test_hostcall_request(
11022                "exec-non-object-case",
11023                HostcallKind::Exec {
11024                    cmd: "bash -lc true".to_string(),
11025                },
11026                serde_json::json!("raw payload"),
11027            ),
11028            test_hostcall_request(
11029                "http-case",
11030                HostcallKind::Http,
11031                serde_json::json!({
11032                    "url": "https://example.com",
11033                    "method": "GET",
11034                }),
11035            ),
11036            test_hostcall_request(
11037                "http-non-object-case",
11038                HostcallKind::Http,
11039                serde_json::json!("https://example.com/raw"),
11040            ),
11041            test_hostcall_request(
11042                "session-case",
11043                HostcallKind::Session {
11044                    op: "get_state".to_string(),
11045                },
11046                serde_json::json!({
11047                    "op": "payload override should lose",
11048                    "includeEntries": true,
11049                }),
11050            ),
11051            test_hostcall_request(
11052                "ui-non-object-case",
11053                HostcallKind::Ui {
11054                    op: "set_status".to_string(),
11055                },
11056                serde_json::json!("ready"),
11057            ),
11058            test_hostcall_request(
11059                "events-null-case",
11060                HostcallKind::Events {
11061                    op: "list_flags".to_string(),
11062                },
11063                Value::Null,
11064            ),
11065            test_hostcall_request(
11066                "log-case",
11067                HostcallKind::Log,
11068                serde_json::json!({
11069                    "level": "info",
11070                    "event": "test.protocol",
11071                    "message": "hello",
11072                }),
11073            ),
11074            test_hostcall_request(
11075                "log-non-object-case",
11076                HostcallKind::Log,
11077                serde_json::json!("raw-log-payload"),
11078            ),
11079            test_hostcall_request(
11080                "log-array-case",
11081                HostcallKind::Log,
11082                serde_json::json!(["raw", "log", "payload"]),
11083            ),
11084            test_hostcall_request("log-null-case", HostcallKind::Log, Value::Null),
11085        ];
11086
11087        for request in requests {
11088            assert_eq!(
11089                protocol_params_from_request(&request),
11090                request.params_for_hash(),
11091                "protocol params shape diverged for {}",
11092                request.call_id
11093            );
11094        }
11095    }
11096
11097    #[test]
11098    fn protocol_params_from_request_preserves_reserved_key_precedence() {
11099        let exec_request = test_hostcall_request(
11100            "exec-precedence",
11101            HostcallKind::Exec {
11102                cmd: "echo from kind".to_string(),
11103            },
11104            serde_json::json!({
11105                "command": "legacy alias",
11106                "cmd": "payload cmd should not win",
11107                "args": ["a", "b"],
11108            }),
11109        );
11110        let exec_params = protocol_params_from_request(&exec_request);
11111        assert_eq!(exec_params["cmd"], serde_json::json!("echo from kind"));
11112        assert_eq!(exec_params.get("command"), None);
11113
11114        for (call_id, kind) in [
11115            (
11116                "session-precedence",
11117                HostcallKind::Session {
11118                    op: "get_state".to_string(),
11119                },
11120            ),
11121            (
11122                "ui-precedence",
11123                HostcallKind::Ui {
11124                    op: "set_status".to_string(),
11125                },
11126            ),
11127            (
11128                "events-precedence",
11129                HostcallKind::Events {
11130                    op: "list_flags".to_string(),
11131                },
11132            ),
11133        ] {
11134            let request = test_hostcall_request(
11135                call_id,
11136                kind.clone(),
11137                serde_json::json!({ "op": "payload op should not win", "x": 1 }),
11138            );
11139            let params = protocol_params_from_request(&request);
11140            let expected_op = match kind {
11141                HostcallKind::Session { ref op }
11142                | HostcallKind::Ui { ref op }
11143                | HostcallKind::Events { ref op } => op.clone(),
11144                _ => unreachable!("loop only includes op-based hostcall kinds"),
11145            };
11146            assert_eq!(params["op"], Value::String(expected_op));
11147        }
11148    }
11149
11150    fn assert_protocol_result_equivalent_except_error_details(
11151        plain: &HostResultPayload,
11152        traced: &HostResultPayload,
11153    ) {
11154        assert_eq!(plain.call_id, traced.call_id);
11155        assert_eq!(plain.output, traced.output);
11156        assert_eq!(plain.is_error, traced.is_error);
11157        assert_eq!(
11158            plain.chunk.as_ref().map(|chunk| {
11159                (
11160                    chunk.index,
11161                    chunk.is_last,
11162                    chunk
11163                        .backpressure
11164                        .as_ref()
11165                        .map(|bp| (bp.credits, bp.delay_ms)),
11166                )
11167            }),
11168            traced.chunk.as_ref().map(|chunk| {
11169                (
11170                    chunk.index,
11171                    chunk.is_last,
11172                    chunk
11173                        .backpressure
11174                        .as_ref()
11175                        .map(|bp| (bp.credits, bp.delay_ms)),
11176                )
11177            })
11178        );
11179        match (plain.error.as_ref(), traced.error.as_ref()) {
11180            (None, None) => {}
11181            (Some(plain_error), Some(traced_error)) => {
11182                assert_eq!(plain_error.code, traced_error.code);
11183                assert_eq!(plain_error.message, traced_error.message);
11184                assert_eq!(plain_error.retryable, traced_error.retryable);
11185            }
11186            _ => panic!("plain and traced protocol results disagree on error presence"),
11187        }
11188    }
11189
11190    #[test]
11191    fn hostcall_outcome_to_protocol_result_success() {
11192        let payload = test_protocol_payload("call-1");
11193        let result = hostcall_outcome_to_protocol_result(
11194            &payload.call_id,
11195            HostcallOutcome::Success(serde_json::json!({ "ok": true })),
11196        );
11197        assert_eq!(result.call_id, "call-1");
11198        assert!(!result.is_error);
11199        assert!(result.error.is_none());
11200        assert!(result.chunk.is_none());
11201        assert!(result.output.is_object());
11202    }
11203
11204    #[test]
11205    fn hostcall_outcome_to_protocol_result_success_wraps_non_object() {
11206        let payload = test_protocol_payload("call-2");
11207        let result = hostcall_outcome_to_protocol_result(
11208            &payload.call_id,
11209            HostcallOutcome::Success(serde_json::json!("plain string")),
11210        );
11211        assert!(!result.is_error);
11212        assert_eq!(
11213            result.output,
11214            serde_json::json!({ "value": "plain string" })
11215        );
11216    }
11217
11218    #[test]
11219    fn hostcall_outcome_to_protocol_result_stream_chunk() {
11220        let payload = test_protocol_payload("call-3");
11221        let result = hostcall_outcome_to_protocol_result(
11222            &payload.call_id,
11223            HostcallOutcome::StreamChunk {
11224                sequence: 5,
11225                chunk: serde_json::json!({ "stdout": "hello\n" }),
11226                is_final: false,
11227            },
11228        );
11229        assert_eq!(result.call_id, "call-3");
11230        assert!(!result.is_error);
11231        assert!(result.error.is_none());
11232        let chunk = result.chunk.expect("should have chunk");
11233        assert_eq!(chunk.index, 5);
11234        assert!(!chunk.is_last);
11235        assert_eq!(result.output["sequence"], 5);
11236        assert!(!result.output["isFinal"].as_bool().unwrap());
11237    }
11238
11239    #[test]
11240    fn hostcall_outcome_to_protocol_result_stream_chunk_final() {
11241        let payload = test_protocol_payload("call-4");
11242        let result = hostcall_outcome_to_protocol_result(
11243            &payload.call_id,
11244            HostcallOutcome::StreamChunk {
11245                sequence: 10,
11246                chunk: serde_json::json!({ "code": 0 }),
11247                is_final: true,
11248            },
11249        );
11250        let chunk = result.chunk.expect("should have chunk");
11251        assert!(chunk.is_last);
11252        assert_eq!(chunk.index, 10);
11253        assert!(result.output["isFinal"].as_bool().unwrap());
11254    }
11255
11256    #[test]
11257    fn hostcall_outcome_to_protocol_result_error() {
11258        let payload = test_protocol_payload("call-5");
11259        let result = hostcall_outcome_to_protocol_result(
11260            &payload.call_id,
11261            HostcallOutcome::Error {
11262                code: "io".to_string(),
11263                message: "disk full".to_string(),
11264            },
11265        );
11266        assert_eq!(result.call_id, "call-5");
11267        assert!(result.is_error);
11268        assert!(result.chunk.is_none());
11269        let error = result.error.expect("should have error");
11270        assert_eq!(error.code, HostCallErrorCode::Io);
11271        assert_eq!(error.message, "disk full");
11272    }
11273
11274    #[test]
11275    fn hostcall_outcome_to_protocol_result_error_unknown_code_maps_to_internal() {
11276        let payload = test_protocol_payload("call-6");
11277        let result = hostcall_outcome_to_protocol_result(
11278            &payload.call_id,
11279            HostcallOutcome::Error {
11280                code: "something_weird".to_string(),
11281                message: "unexpected".to_string(),
11282            },
11283        );
11284        let error = result.error.expect("should have error");
11285        assert_eq!(error.code, HostCallErrorCode::Internal);
11286    }
11287
11288    #[test]
11289    fn hostcall_outcome_to_protocol_result_error_normalizes_mixed_case_code() {
11290        let payload = test_protocol_payload("call-6b");
11291        let result = hostcall_outcome_to_protocol_result(
11292            &payload.call_id,
11293            HostcallOutcome::Error {
11294                code: "  Invalid_Request  ".to_string(),
11295                message: "normalized".to_string(),
11296            },
11297        );
11298        let error = result.error.expect("should have error");
11299        assert_eq!(error.code, HostCallErrorCode::InvalidRequest);
11300        assert_eq!(error.message, "normalized");
11301    }
11302
11303    #[test]
11304    fn hostcall_outcome_to_protocol_result_error_normalizes_denied_timeout_and_tool_error_alias() {
11305        let cases = [
11306            ("  DeNied ", HostCallErrorCode::Denied),
11307            ("  TimeOut ", HostCallErrorCode::Timeout),
11308            ("  TOOL_ERROR ", HostCallErrorCode::Io),
11309        ];
11310
11311        for (idx, (raw_code, expected_code)) in cases.into_iter().enumerate() {
11312            let payload = test_protocol_payload(&format!("call-plain-normalize-{idx}"));
11313            let message = format!("normalized-{idx}");
11314            let result = hostcall_outcome_to_protocol_result(
11315                &payload.call_id,
11316                HostcallOutcome::Error {
11317                    code: raw_code.to_string(),
11318                    message: message.clone(),
11319                },
11320            );
11321
11322            let error = result.error.expect("should have error");
11323            assert_eq!(error.code, expected_code, "raw code: {raw_code}");
11324            assert_eq!(error.message, message);
11325        }
11326    }
11327
11328    #[test]
11329    fn hostcall_outcome_to_protocol_result_with_trace_success_equivalent_to_plain() {
11330        let payload = test_protocol_payload("call-trace-success");
11331        let outcome = HostcallOutcome::Success(serde_json::json!({
11332            "ok": true,
11333            "nested": { "n": 7 }
11334        }));
11335        let plain = hostcall_outcome_to_protocol_result(&payload.call_id, outcome.clone());
11336        let traced = hostcall_outcome_to_protocol_result_with_trace(&payload, outcome);
11337
11338        assert_protocol_result_equivalent_except_error_details(&plain, &traced);
11339        assert!(traced.error.is_none());
11340    }
11341
11342    #[test]
11343    fn hostcall_outcome_to_protocol_result_with_trace_stream_equivalent_to_plain() {
11344        let payload = test_protocol_payload("call-trace-stream");
11345        let outcome = HostcallOutcome::StreamChunk {
11346            sequence: 3,
11347            chunk: serde_json::json!({ "stdout": "chunk" }),
11348            is_final: false,
11349        };
11350        let plain = hostcall_outcome_to_protocol_result(&payload.call_id, outcome.clone());
11351        let traced = hostcall_outcome_to_protocol_result_with_trace(&payload, outcome);
11352
11353        assert_protocol_result_equivalent_except_error_details(&plain, &traced);
11354        assert!(traced.error.is_none());
11355    }
11356
11357    #[test]
11358    fn hostcall_outcome_to_protocol_result_with_trace_error_adds_details_without_mutating_error_core()
11359     {
11360        let mut payload = test_protocol_payload("call-trace-error");
11361        payload.method = "tool".to_string();
11362        payload.params = serde_json::json!({ "zeta": 1, "alpha": 2 });
11363        let outcome = HostcallOutcome::Error {
11364            code: "invalid_request".to_string(),
11365            message: "invalid payload".to_string(),
11366        };
11367        let plain = hostcall_outcome_to_protocol_result(&payload.call_id, outcome.clone());
11368        let traced = hostcall_outcome_to_protocol_result_with_trace(&payload, outcome);
11369
11370        assert_protocol_result_equivalent_except_error_details(&plain, &traced);
11371
11372        let plain_error = plain.error.expect("plain conversion should include error");
11373        assert!(
11374            plain_error.details.is_none(),
11375            "plain conversion should not inject trace details"
11376        );
11377        let traced_error = traced.error.expect("trace conversion should include error");
11378        let details = traced_error
11379            .details
11380            .expect("trace conversion should include structured details");
11381        assert_eq!(
11382            details["dispatcherDecisionTrace"]["fallbackReason"],
11383            serde_json::json!("schema_validation_failed")
11384        );
11385        assert_eq!(
11386            details["schemaDiff"]["observedParamKeys"],
11387            serde_json::json!(["alpha", "zeta"])
11388        );
11389        assert_eq!(
11390            details["extensionInput"]["callId"],
11391            serde_json::json!("call-trace-error")
11392        );
11393        assert_eq!(
11394            details["extensionOutput"]["code"],
11395            serde_json::json!("invalid_request")
11396        );
11397    }
11398
11399    #[test]
11400    fn hostcall_outcome_to_protocol_result_with_trace_normalizes_invalid_request_taxonomy() {
11401        let mut known_method_payload = test_protocol_payload("call-trace-error-known");
11402        known_method_payload.method = " TOOL ".to_string();
11403        let known_method_result = hostcall_outcome_to_protocol_result_with_trace(
11404            &known_method_payload,
11405            HostcallOutcome::Error {
11406                code: "  INVALID_REQUEST ".to_string(),
11407                message: "bad request".to_string(),
11408            },
11409        );
11410        let known_method_error = known_method_result.error.expect("expected error");
11411        assert_eq!(known_method_error.code, HostCallErrorCode::InvalidRequest);
11412        let known_details = known_method_error.details.expect("expected details");
11413        assert_eq!(
11414            known_details["dispatcherDecisionTrace"]["fallbackReason"],
11415            serde_json::json!("schema_validation_failed")
11416        );
11417
11418        let mut unknown_method_payload = test_protocol_payload("call-trace-error-unknown");
11419        unknown_method_payload.method = "custom_method".to_string();
11420        let unknown_method_result = hostcall_outcome_to_protocol_result_with_trace(
11421            &unknown_method_payload,
11422            HostcallOutcome::Error {
11423                code: "  INVALID_REQUEST ".to_string(),
11424                message: "bad request".to_string(),
11425            },
11426        );
11427        let unknown_method_error = unknown_method_result.error.expect("expected error");
11428        assert_eq!(unknown_method_error.code, HostCallErrorCode::InvalidRequest);
11429        let unknown_details = unknown_method_error.details.expect("expected details");
11430        assert_eq!(
11431            unknown_details["dispatcherDecisionTrace"]["fallbackReason"],
11432            serde_json::json!("unsupported_method_fallback")
11433        );
11434    }
11435
11436    #[test]
11437    fn hostcall_outcome_to_protocol_result_with_trace_normalizes_tool_error_taxonomy() {
11438        let mut payload = test_protocol_payload("call-trace-error-tool");
11439        payload.method = "tool".to_string();
11440        let result = hostcall_outcome_to_protocol_result_with_trace(
11441            &payload,
11442            HostcallOutcome::Error {
11443                code: "  TOOL_ERROR ".to_string(),
11444                message: "handler exploded".to_string(),
11445            },
11446        );
11447
11448        let error = result.error.expect("expected error");
11449        assert_eq!(error.code, HostCallErrorCode::Io);
11450        let details = error.details.expect("expected details");
11451        assert_eq!(
11452            details["dispatcherDecisionTrace"]["fallbackReason"],
11453            serde_json::json!("handler_error")
11454        );
11455        assert_eq!(
11456            details["extensionOutput"]["code"],
11457            serde_json::json!("  TOOL_ERROR ")
11458        );
11459    }
11460
11461    #[test]
11462    fn hostcall_outcome_to_protocol_result_with_trace_normalizes_timeout_taxonomy() {
11463        let mut payload = test_protocol_payload("call-trace-error-timeout");
11464        payload.method = "exec".to_string();
11465        let result = hostcall_outcome_to_protocol_result_with_trace(
11466            &payload,
11467            HostcallOutcome::Error {
11468                code: "  TimeOut  ".to_string(),
11469                message: "handler timed out".to_string(),
11470            },
11471        );
11472
11473        let error = result.error.expect("expected error");
11474        assert_eq!(error.code, HostCallErrorCode::Timeout);
11475        let details = error.details.expect("expected details");
11476        assert_eq!(
11477            details["dispatcherDecisionTrace"]["fallbackReason"],
11478            serde_json::json!("handler_timeout")
11479        );
11480        assert_eq!(
11481            details["extensionOutput"]["code"],
11482            serde_json::json!("  TimeOut  ")
11483        );
11484    }
11485
11486    #[test]
11487    fn hostcall_outcome_to_protocol_result_with_trace_normalizes_denied_taxonomy() {
11488        let mut payload = test_protocol_payload("call-trace-error-denied");
11489        payload.method = "session".to_string();
11490        let result = hostcall_outcome_to_protocol_result_with_trace(
11491            &payload,
11492            HostcallOutcome::Error {
11493                code: "  DeNied ".to_string(),
11494                message: "blocked by policy".to_string(),
11495            },
11496        );
11497
11498        let error = result.error.expect("expected error");
11499        assert_eq!(error.code, HostCallErrorCode::Denied);
11500        let details = error.details.expect("expected details");
11501        assert_eq!(
11502            details["dispatcherDecisionTrace"]["fallbackReason"],
11503            serde_json::json!("policy_denied")
11504        );
11505        assert_eq!(
11506            details["extensionOutput"]["code"],
11507            serde_json::json!("  DeNied ")
11508        );
11509    }
11510
11511    #[test]
11512    fn hostcall_outcome_to_protocol_result_with_trace_normalizes_unknown_code_to_internal_taxonomy()
11513    {
11514        let mut payload = test_protocol_payload("call-trace-error-unknown-code");
11515        payload.method = "tool".to_string();
11516        let result = hostcall_outcome_to_protocol_result_with_trace(
11517            &payload,
11518            HostcallOutcome::Error {
11519                code: "  SOME_NEW_CODE ".to_string(),
11520                message: "unexpected runtime state".to_string(),
11521            },
11522        );
11523
11524        let error = result.error.expect("expected error");
11525        assert_eq!(error.code, HostCallErrorCode::Internal);
11526        let details = error.details.expect("expected details");
11527        assert_eq!(
11528            details["dispatcherDecisionTrace"]["fallbackReason"],
11529            serde_json::json!("runtime_internal_error")
11530        );
11531        assert_eq!(
11532            details["extensionOutput"]["code"],
11533            serde_json::json!("  SOME_NEW_CODE ")
11534        );
11535    }
11536
11537    #[test]
11538    fn hostcall_code_to_str_roundtrips_all_variants() {
11539        use crate::connectors::HostCallErrorCode;
11540        assert_eq!(hostcall_code_to_str(HostCallErrorCode::Timeout), "timeout");
11541        assert_eq!(hostcall_code_to_str(HostCallErrorCode::Denied), "denied");
11542        assert_eq!(hostcall_code_to_str(HostCallErrorCode::Io), "io");
11543        assert_eq!(
11544            hostcall_code_to_str(HostCallErrorCode::InvalidRequest),
11545            "invalid_request"
11546        );
11547        assert_eq!(
11548            hostcall_code_to_str(HostCallErrorCode::Internal),
11549            "internal"
11550        );
11551    }
11552
11553    // -----------------------------------------------------------------------
11554    // Protocol dispatch for all method types
11555    // -----------------------------------------------------------------------
11556
11557    #[test]
11558    fn protocol_dispatch_tool_success() {
11559        futures::executor::block_on(async {
11560            let temp_dir = tempfile::tempdir().expect("tempdir");
11561            std::fs::write(temp_dir.path().join("file.txt"), "protocol test content")
11562                .expect("write");
11563
11564            let runtime = Rc::new(
11565                PiJsRuntime::with_clock(DeterministicClock::new(0))
11566                    .await
11567                    .expect("runtime"),
11568            );
11569            let dispatcher = ExtensionDispatcher::new_with_policy(
11570                Rc::clone(&runtime),
11571                Arc::new(ToolRegistry::new(&["read"], temp_dir.path(), None)),
11572                Arc::new(HttpConnector::with_defaults()),
11573                Arc::new(NullSession),
11574                Arc::new(NullUiHandler),
11575                temp_dir.path().to_path_buf(),
11576                ExtensionPolicy::from_profile(PolicyProfile::Permissive),
11577            );
11578
11579            let message = ExtensionMessage {
11580                id: "msg-tool-proto".to_string(),
11581                version: PROTOCOL_VERSION.to_string(),
11582                body: ExtensionBody::HostCall(HostCallPayload {
11583                    call_id: "call-tool-proto".to_string(),
11584                    capability: "read".to_string(),
11585                    method: "tool".to_string(),
11586                    params: serde_json::json!({ "name": "read", "input": { "path": "file.txt" } }),
11587                    timeout_ms: None,
11588                    cancel_token: None,
11589                    context: None,
11590                }),
11591            };
11592
11593            let response = dispatcher
11594                .dispatch_protocol_message(message)
11595                .await
11596                .expect("protocol tool dispatch");
11597
11598            match response.body {
11599                ExtensionBody::HostResult(result) => {
11600                    assert!(!result.is_error, "expected success: {result:?}");
11601                    assert!(result.output.is_object());
11602                }
11603                other => panic!("expected host_result, got {other:?}"),
11604            }
11605        });
11606    }
11607
11608    #[test]
11609    fn protocol_dispatch_tool_missing_name_returns_invalid_request() {
11610        futures::executor::block_on(async {
11611            let runtime = Rc::new(
11612                PiJsRuntime::with_clock(DeterministicClock::new(0))
11613                    .await
11614                    .expect("runtime"),
11615            );
11616            let dispatcher = build_dispatcher(Rc::clone(&runtime));
11617
11618            let message = ExtensionMessage {
11619                id: "msg-tool-noname".to_string(),
11620                version: PROTOCOL_VERSION.to_string(),
11621                body: ExtensionBody::HostCall(HostCallPayload {
11622                    call_id: "call-tool-noname".to_string(),
11623                    capability: "tool".to_string(),
11624                    method: "tool".to_string(),
11625                    params: serde_json::json!({ "input": {} }),
11626                    timeout_ms: None,
11627                    cancel_token: None,
11628                    context: None,
11629                }),
11630            };
11631
11632            let response = dispatcher
11633                .dispatch_protocol_message(message)
11634                .await
11635                .expect("protocol dispatch");
11636
11637            match response.body {
11638                ExtensionBody::HostResult(result) => {
11639                    assert!(result.is_error);
11640                    let error = result.error.expect("error");
11641                    assert_eq!(error.code, HostCallErrorCode::InvalidRequest);
11642                    assert!(
11643                        error.message.contains("method") || error.message.contains("tool"),
11644                        "error should mention 'method' or 'tool': {}",
11645                        error.message
11646                    );
11647                }
11648                other => panic!("expected host_result, got {other:?}"),
11649            }
11650        });
11651    }
11652
11653    #[test]
11654    fn protocol_dispatch_tool_empty_name_returns_invalid_request() {
11655        futures::executor::block_on(async {
11656            let runtime = Rc::new(
11657                PiJsRuntime::with_clock(DeterministicClock::new(0))
11658                    .await
11659                    .expect("runtime"),
11660            );
11661            let dispatcher = build_dispatcher(Rc::clone(&runtime));
11662
11663            let message = ExtensionMessage {
11664                id: "msg-tool-empty".to_string(),
11665                version: PROTOCOL_VERSION.to_string(),
11666                body: ExtensionBody::HostCall(HostCallPayload {
11667                    call_id: "call-tool-empty".to_string(),
11668                    capability: "tool".to_string(),
11669                    method: "tool".to_string(),
11670                    params: serde_json::json!({ "name": "", "input": {} }),
11671                    timeout_ms: None,
11672                    cancel_token: None,
11673                    context: None,
11674                }),
11675            };
11676
11677            let response = dispatcher
11678                .dispatch_protocol_message(message)
11679                .await
11680                .expect("protocol dispatch");
11681
11682            match response.body {
11683                ExtensionBody::HostResult(result) => {
11684                    assert!(result.is_error);
11685                    let error = result.error.expect("error");
11686                    assert_eq!(error.code, HostCallErrorCode::InvalidRequest);
11687                }
11688                other => panic!("expected host_result, got {other:?}"),
11689            }
11690        });
11691    }
11692
11693    #[test]
11694    fn protocol_dispatch_http_success() {
11695        futures::executor::block_on(async {
11696            let addr = spawn_http_server("protocol http ok");
11697
11698            let runtime = Rc::new(
11699                PiJsRuntime::with_clock(DeterministicClock::new(0))
11700                    .await
11701                    .expect("runtime"),
11702            );
11703            let dispatcher = ExtensionDispatcher::new_with_policy(
11704                Rc::clone(&runtime),
11705                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
11706                Arc::new(HttpConnector::new(HttpConnectorConfig {
11707                    default_timeout_ms: 5000,
11708                    require_tls: false,
11709                    ..HttpConnectorConfig::default()
11710                })),
11711                Arc::new(NullSession),
11712                Arc::new(NullUiHandler),
11713                PathBuf::from("."),
11714                ExtensionPolicy::from_profile(PolicyProfile::Permissive),
11715            );
11716
11717            let message = ExtensionMessage {
11718                id: "msg-http-proto".to_string(),
11719                version: PROTOCOL_VERSION.to_string(),
11720                body: ExtensionBody::HostCall(HostCallPayload {
11721                    call_id: "call-http-proto".to_string(),
11722                    capability: "http".to_string(),
11723                    method: "http".to_string(),
11724                    params: serde_json::json!({
11725                        "url": format!("http://{addr}/test"),
11726                        "method": "GET",
11727                    }),
11728                    timeout_ms: None,
11729                    cancel_token: None,
11730                    context: None,
11731                }),
11732            };
11733
11734            let response = dispatcher
11735                .dispatch_protocol_message(message)
11736                .await
11737                .expect("protocol http dispatch");
11738
11739            match response.body {
11740                ExtensionBody::HostResult(result) => {
11741                    assert!(!result.is_error, "expected success: {result:?}");
11742                }
11743                other => panic!("expected host_result, got {other:?}"),
11744            }
11745        });
11746    }
11747
11748    #[test]
11749    fn protocol_dispatch_ui_success() {
11750        futures::executor::block_on(async {
11751            let runtime = Rc::new(
11752                PiJsRuntime::with_clock(DeterministicClock::new(0))
11753                    .await
11754                    .expect("runtime"),
11755            );
11756            let dispatcher = ExtensionDispatcher::new_with_policy(
11757                Rc::clone(&runtime),
11758                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
11759                Arc::new(HttpConnector::with_defaults()),
11760                Arc::new(NullSession),
11761                Arc::new(NullUiHandler),
11762                PathBuf::from("."),
11763                ExtensionPolicy::from_profile(PolicyProfile::Permissive),
11764            );
11765
11766            let message = ExtensionMessage {
11767                id: "msg-ui-proto".to_string(),
11768                version: PROTOCOL_VERSION.to_string(),
11769                body: ExtensionBody::HostCall(HostCallPayload {
11770                    call_id: "call-ui-proto".to_string(),
11771                    capability: "ui".to_string(),
11772                    method: "ui".to_string(),
11773                    params: serde_json::json!({ "op": "notification", "message": "test" }),
11774                    timeout_ms: None,
11775                    cancel_token: None,
11776                    context: None,
11777                }),
11778            };
11779
11780            let response = dispatcher
11781                .dispatch_protocol_message(message)
11782                .await
11783                .expect("protocol ui dispatch");
11784
11785            match response.body {
11786                ExtensionBody::HostResult(result) => {
11787                    assert!(!result.is_error, "expected success: {result:?}");
11788                }
11789                other => panic!("expected host_result, got {other:?}"),
11790            }
11791        });
11792    }
11793
11794    #[test]
11795    fn protocol_dispatch_ui_missing_op_returns_error() {
11796        futures::executor::block_on(async {
11797            let runtime = Rc::new(
11798                PiJsRuntime::with_clock(DeterministicClock::new(0))
11799                    .await
11800                    .expect("runtime"),
11801            );
11802            let dispatcher = build_dispatcher(Rc::clone(&runtime));
11803
11804            let message = ExtensionMessage {
11805                id: "msg-ui-noop".to_string(),
11806                version: PROTOCOL_VERSION.to_string(),
11807                body: ExtensionBody::HostCall(HostCallPayload {
11808                    call_id: "call-ui-noop".to_string(),
11809                    capability: "ui".to_string(),
11810                    method: "ui".to_string(),
11811                    params: serde_json::json!({ "message": "test" }),
11812                    timeout_ms: None,
11813                    cancel_token: None,
11814                    context: None,
11815                }),
11816            };
11817
11818            let response = dispatcher
11819                .dispatch_protocol_message(message)
11820                .await
11821                .expect("protocol dispatch");
11822
11823            match response.body {
11824                ExtensionBody::HostResult(result) => {
11825                    assert!(result.is_error);
11826                    let error = result.error.expect("error");
11827                    assert_eq!(error.code, HostCallErrorCode::InvalidRequest);
11828                    assert!(
11829                        error.message.contains("op"),
11830                        "error should mention 'op': {}",
11831                        error.message
11832                    );
11833                }
11834                other => panic!("expected host_result, got {other:?}"),
11835            }
11836        });
11837    }
11838
11839    #[test]
11840    fn protocol_dispatch_events_missing_op_returns_error() {
11841        futures::executor::block_on(async {
11842            let runtime = Rc::new(
11843                PiJsRuntime::with_clock(DeterministicClock::new(0))
11844                    .await
11845                    .expect("runtime"),
11846            );
11847            let dispatcher = build_dispatcher(Rc::clone(&runtime));
11848
11849            let message = ExtensionMessage {
11850                id: "msg-events-noop".to_string(),
11851                version: PROTOCOL_VERSION.to_string(),
11852                body: ExtensionBody::HostCall(HostCallPayload {
11853                    call_id: "call-events-noop".to_string(),
11854                    capability: "events".to_string(),
11855                    method: "events".to_string(),
11856                    params: serde_json::json!({ "data": {} }),
11857                    timeout_ms: None,
11858                    cancel_token: None,
11859                    context: None,
11860                }),
11861            };
11862
11863            let response = dispatcher
11864                .dispatch_protocol_message(message)
11865                .await
11866                .expect("protocol dispatch");
11867
11868            match response.body {
11869                ExtensionBody::HostResult(result) => {
11870                    assert!(result.is_error);
11871                    let error = result.error.expect("error");
11872                    assert_eq!(error.code, HostCallErrorCode::InvalidRequest);
11873                    assert!(
11874                        error.message.contains("op"),
11875                        "error should mention 'op': {}",
11876                        error.message
11877                    );
11878                }
11879                other => panic!("expected host_result, got {other:?}"),
11880            }
11881        });
11882    }
11883
11884    #[test]
11885    fn protocol_dispatch_log_returns_success() {
11886        futures::executor::block_on(async {
11887            let runtime = Rc::new(
11888                PiJsRuntime::with_clock(DeterministicClock::new(0))
11889                    .await
11890                    .expect("runtime"),
11891            );
11892            let dispatcher = build_dispatcher(Rc::clone(&runtime));
11893
11894            let message = ExtensionMessage {
11895                id: "msg-log-proto".to_string(),
11896                version: PROTOCOL_VERSION.to_string(),
11897                body: ExtensionBody::HostCall(HostCallPayload {
11898                    call_id: "call-log-proto".to_string(),
11899                    capability: "log".to_string(),
11900                    method: "log".to_string(),
11901                    params: serde_json::json!({ "message": "test log" }),
11902                    timeout_ms: None,
11903                    cancel_token: None,
11904                    context: None,
11905                }),
11906            };
11907
11908            let response = dispatcher
11909                .dispatch_protocol_message(message)
11910                .await
11911                .expect("protocol log dispatch");
11912
11913            match response.body {
11914                ExtensionBody::HostResult(result) => {
11915                    assert!(!result.is_error, "log dispatch should succeed: {result:?}");
11916                }
11917                other => panic!("expected host_result, got {other:?}"),
11918            }
11919        });
11920    }
11921
11922    fn regime_signal(
11923        queue_depth: f64,
11924        service_time_us: f64,
11925        opcode_entropy: f64,
11926        llc_miss_rate: f64,
11927    ) -> RegimeSignal {
11928        RegimeSignal {
11929            queue_depth,
11930            service_time_us,
11931            opcode_entropy,
11932            llc_miss_rate,
11933        }
11934    }
11935
11936    fn drive_detector_to_interleaved(detector: &mut RegimeShiftDetector) {
11937        for _ in 0..64 {
11938            let _ = detector.observe(regime_signal(1.0, 600.0, 0.8, 0.02));
11939        }
11940        for _ in 0..48 {
11941            let observation = detector.observe(regime_signal(40.0, 14_000.0, 2.6, 0.92));
11942            if observation.transition == Some(RegimeTransition::EnterInterleavedBatching) {
11943                break;
11944            }
11945        }
11946    }
11947
11948    #[test]
11949    fn regime_detector_switches_to_interleaved_on_sustained_upshift() {
11950        let mut detector = RegimeShiftDetector::default();
11951        let mut switched = false;
11952
11953        for _ in 0..64 {
11954            let _ = detector.observe(regime_signal(1.0, 700.0, 0.9, 0.03));
11955        }
11956        for _ in 0..64 {
11957            let observation = detector.observe(regime_signal(42.0, 16_000.0, 2.8, 0.95));
11958            if observation.transition == Some(RegimeTransition::EnterInterleavedBatching) {
11959                switched = true;
11960                break;
11961            }
11962        }
11963
11964        assert!(
11965            switched,
11966            "detector should switch on sustained high-contention shift"
11967        );
11968        assert_eq!(
11969            detector.current_mode(),
11970            RegimeAdaptationMode::InterleavedBatching
11971        );
11972    }
11973
11974    #[test]
11975    fn regime_detector_avoids_false_positives_on_stationary_noise() {
11976        let mut detector = RegimeShiftDetector::default();
11977        let mut transitions = 0_usize;
11978
11979        for idx in 0..320 {
11980            let jitter = match idx % 5 {
11981                0 => -70.0,
11982                1 => -20.0,
11983                2 => 0.0,
11984                3 => 35.0,
11985                _ => 80.0,
11986            };
11987            let queue_depth = if idx % 3 == 0 { 2.0 } else { 1.0 };
11988            let entropy = if idx % 7 == 0 { 1.2 } else { 1.0 };
11989            let observation =
11990                detector.observe(regime_signal(queue_depth, 900.0 + jitter, entropy, 0.06));
11991            if observation.transition.is_some() {
11992                transitions = transitions.saturating_add(1);
11993            }
11994        }
11995
11996        assert_eq!(
11997            transitions, 0,
11998            "stationary noise should not trigger transitions"
11999        );
12000        assert_eq!(
12001            detector.current_mode(),
12002            RegimeAdaptationMode::SequentialFastPath
12003        );
12004    }
12005
12006    #[test]
12007    fn regime_detector_hysteresis_limits_thrash() {
12008        let mut detector = RegimeShiftDetector::default();
12009        drive_detector_to_interleaved(&mut detector);
12010        assert_eq!(
12011            detector.current_mode(),
12012            RegimeAdaptationMode::InterleavedBatching
12013        );
12014
12015        let mut transitions = 0_usize;
12016        for idx in 0..200 {
12017            let signal = if idx % 2 == 0 {
12018                regime_signal(36.0, 12_500.0, 2.4, 0.88)
12019            } else {
12020                regime_signal(5.0, 2_200.0, 1.1, 0.18)
12021            };
12022            let observation = detector.observe(signal);
12023            if observation.transition.is_some() {
12024                transitions = transitions.saturating_add(1);
12025            }
12026        }
12027
12028        assert!(
12029            transitions <= 5,
12030            "hysteresis/cooldown should prevent oscillation: observed {transitions} transitions"
12031        );
12032    }
12033
12034    #[test]
12035    fn regime_detector_fallbacks_when_workload_cools() {
12036        let mut detector = RegimeShiftDetector::default();
12037        drive_detector_to_interleaved(&mut detector);
12038        assert_eq!(
12039            detector.current_mode(),
12040            RegimeAdaptationMode::InterleavedBatching
12041        );
12042
12043        let mut fallback_triggered = false;
12044        let mut returned_to_sequential = false;
12045        for _ in 0..40 {
12046            let observation = detector.observe(regime_signal(0.0, 450.0, 0.2, 0.0));
12047            if observation.fallback_triggered {
12048                fallback_triggered = true;
12049            }
12050            if observation.transition == Some(RegimeTransition::ReturnToSequentialFastPath) {
12051                returned_to_sequential = true;
12052            }
12053        }
12054
12055        assert!(
12056            fallback_triggered,
12057            "low queue/latency should trigger conservative fallback"
12058        );
12059        assert!(
12060            returned_to_sequential,
12061            "fallback should report an explicit transition"
12062        );
12063        assert_eq!(
12064            detector.current_mode(),
12065            RegimeAdaptationMode::SequentialFastPath
12066        );
12067    }
12068
12069    #[test]
12070    fn rollout_gate_blocks_cherry_picked_high_contention_claims() {
12071        let mut detector = RegimeShiftDetector::default();
12072        let mut saw_block = false;
12073        let mut switched = false;
12074
12075        for _ in 0..160 {
12076            let observation = detector.observe(regime_signal(46.0, 17_500.0, 3.0, 0.95));
12077            if observation.rollout_blocked_cherry_picked {
12078                saw_block = true;
12079            }
12080            if observation.transition == Some(RegimeTransition::EnterInterleavedBatching) {
12081                switched = true;
12082            }
12083        }
12084
12085        assert!(saw_block, "gate should surface cherry-pick blocking signal");
12086        assert!(!switched, "high-only stream must not promote rollout");
12087        assert_eq!(
12088            detector.current_mode(),
12089            RegimeAdaptationMode::SequentialFastPath
12090        );
12091    }
12092
12093    #[test]
12094    fn rollout_gate_promotes_after_stratified_evidence_reaches_threshold() {
12095        let mut detector = RegimeShiftDetector::default();
12096        let mut promoted = false;
12097
12098        for _ in 0..80 {
12099            let _ = detector.observe(regime_signal(1.0, 700.0, 0.9, 0.03));
12100        }
12101        for _ in 0..96 {
12102            let observation = detector.observe(regime_signal(42.0, 16_000.0, 2.8, 0.95));
12103            if observation.transition == Some(RegimeTransition::EnterInterleavedBatching) {
12104                promoted = true;
12105                assert_eq!(
12106                    observation.rollout_action,
12107                    RolloutGateAction::PromoteInterleaved
12108                );
12109                assert!(
12110                    observation.rollout_promote_e_process >= observation.rollout_evidence_threshold
12111                );
12112                assert!(observation.rollout_coverage_ready);
12113                assert!(
12114                    observation.rollout_expected_loss.promote
12115                        < observation.rollout_expected_loss.hold
12116                );
12117                break;
12118            }
12119        }
12120
12121        assert!(
12122            promoted,
12123            "stratified stream should promote interleaved batching"
12124        );
12125        assert_eq!(
12126            detector.current_mode(),
12127            RegimeAdaptationMode::InterleavedBatching
12128        );
12129    }
12130
12131    #[test]
12132    fn rollout_gate_rolls_back_after_stratified_regression_evidence() {
12133        let mut detector = RegimeShiftDetector::default();
12134        drive_detector_to_interleaved(&mut detector);
12135        assert_eq!(
12136            detector.current_mode(),
12137            RegimeAdaptationMode::InterleavedBatching
12138        );
12139
12140        let mut rolled_back = false;
12141        for _ in 0..320 {
12142            let observation = detector.observe(regime_signal(1.4, 1_500.0, 0.6, 0.02));
12143            if observation.transition == Some(RegimeTransition::ReturnToSequentialFastPath) {
12144                rolled_back = true;
12145                assert_eq!(
12146                    observation.rollout_action,
12147                    RolloutGateAction::RollbackSequential
12148                );
12149                assert!(
12150                    observation.rollout_rollback_e_process
12151                        >= observation.rollout_evidence_threshold
12152                );
12153                assert!(observation.rollout_coverage_ready);
12154                assert!(
12155                    observation.rollout_expected_loss.rollback
12156                        < observation.rollout_expected_loss.hold
12157                );
12158                break;
12159            }
12160        }
12161
12162        assert!(
12163            rolled_back,
12164            "low-contention regression stream should trigger rollout rollback"
12165        );
12166        assert_eq!(
12167            detector.current_mode(),
12168            RegimeAdaptationMode::SequentialFastPath
12169        );
12170    }
12171
12172    #[test]
12173    fn dual_exec_sampling_is_deterministic_for_same_request() {
12174        let request = HostcallRequest {
12175            call_id: "sample-deterministic".to_string(),
12176            kind: HostcallKind::Session {
12177                op: "get_state".to_string(),
12178            },
12179            payload: serde_json::json!({}),
12180            trace_id: 77,
12181            extension_id: Some("ext.det".to_string()),
12182        };
12183        let first = should_sample_shadow_dual_exec(&request, 100_000);
12184        for _ in 0..16 {
12185            assert_eq!(should_sample_shadow_dual_exec(&request, 100_000), first);
12186        }
12187    }
12188
12189    #[test]
12190    fn dual_exec_sampling_respects_zero_and_full_scale_boundaries() {
12191        let request = HostcallRequest {
12192            call_id: "sample-boundary".to_string(),
12193            kind: HostcallKind::Session {
12194                op: "get_state".to_string(),
12195            },
12196            payload: serde_json::json!({}),
12197            trace_id: 91,
12198            extension_id: Some("ext.boundary".to_string()),
12199        };
12200
12201        assert!(!should_sample_shadow_dual_exec(&request, 0));
12202        assert!(should_sample_shadow_dual_exec(
12203            &request,
12204            DUAL_EXEC_SAMPLE_MODULUS_PPM
12205        ));
12206        assert!(should_sample_shadow_dual_exec(
12207            &request,
12208            DUAL_EXEC_SAMPLE_MODULUS_PPM.saturating_add(1)
12209        ));
12210    }
12211
12212    #[test]
12213    fn normalized_shadow_op_is_deterministic_across_format_variants() {
12214        assert_eq!(normalized_shadow_op(" get__state "), "getstate");
12215        assert_eq!(normalized_shadow_op("GET_STATE"), "getstate");
12216        assert_eq!(normalized_shadow_op("GeT_sTaTe"), "getstate");
12217        assert_eq!(normalized_shadow_op("list_flags"), "listflags");
12218    }
12219
12220    #[test]
12221    fn shadow_safe_classification_accepts_normalized_read_only_ops() {
12222        let session_request = HostcallRequest {
12223            call_id: "shadow-safe-session".to_string(),
12224            kind: HostcallKind::Session {
12225                op: "  GET__MESSAGES ".to_string(),
12226            },
12227            payload: serde_json::json!({}),
12228            trace_id: 5,
12229            extension_id: Some("ext.shadow.safe".to_string()),
12230        };
12231        let events_request = HostcallRequest {
12232            call_id: "shadow-safe-events".to_string(),
12233            kind: HostcallKind::Events {
12234                op: " list_flags ".to_string(),
12235            },
12236            payload: serde_json::json!({}),
12237            trace_id: 6,
12238            extension_id: Some("ext.shadow.safe".to_string()),
12239        };
12240        let tool_request = HostcallRequest {
12241            call_id: "shadow-safe-tool".to_string(),
12242            kind: HostcallKind::Tool {
12243                name: " read ".to_string(),
12244            },
12245            payload: serde_json::json!({}),
12246            trace_id: 7,
12247            extension_id: Some("ext.shadow.safe".to_string()),
12248        };
12249
12250        assert!(is_shadow_safe_request(&session_request));
12251        assert!(is_shadow_safe_request(&events_request));
12252        assert!(is_shadow_safe_request(&tool_request));
12253    }
12254
12255    #[test]
12256    fn shadow_safe_classification_rejects_mutating_and_unsafe_kinds() {
12257        let requests = [
12258            (
12259                "session mutate",
12260                HostcallRequest {
12261                    call_id: "shadow-unsafe-session".to_string(),
12262                    kind: HostcallKind::Session {
12263                        op: "append_message".to_string(),
12264                    },
12265                    payload: serde_json::json!({}),
12266                    trace_id: 11,
12267                    extension_id: Some("ext.shadow.unsafe".to_string()),
12268                },
12269            ),
12270            (
12271                "events mutate",
12272                HostcallRequest {
12273                    call_id: "shadow-unsafe-events".to_string(),
12274                    kind: HostcallKind::Events {
12275                        op: "set_flag".to_string(),
12276                    },
12277                    payload: serde_json::json!({}),
12278                    trace_id: 12,
12279                    extension_id: Some("ext.shadow.unsafe".to_string()),
12280                },
12281            ),
12282            (
12283                "tool mutate",
12284                HostcallRequest {
12285                    call_id: "shadow-unsafe-tool".to_string(),
12286                    kind: HostcallKind::Tool {
12287                        name: "write".to_string(),
12288                    },
12289                    payload: serde_json::json!({}),
12290                    trace_id: 13,
12291                    extension_id: Some("ext.shadow.unsafe".to_string()),
12292                },
12293            ),
12294            (
12295                "exec",
12296                HostcallRequest {
12297                    call_id: "shadow-unsafe-exec".to_string(),
12298                    kind: HostcallKind::Exec {
12299                        cmd: "echo nope".to_string(),
12300                    },
12301                    payload: serde_json::json!({}),
12302                    trace_id: 14,
12303                    extension_id: Some("ext.shadow.unsafe".to_string()),
12304                },
12305            ),
12306            (
12307                "http",
12308                HostcallRequest {
12309                    call_id: "shadow-unsafe-http".to_string(),
12310                    kind: HostcallKind::Http,
12311                    payload: serde_json::json!({}),
12312                    trace_id: 15,
12313                    extension_id: Some("ext.shadow.unsafe".to_string()),
12314                },
12315            ),
12316            (
12317                "ui",
12318                HostcallRequest {
12319                    call_id: "shadow-unsafe-ui".to_string(),
12320                    kind: HostcallKind::Ui {
12321                        op: "prompt".to_string(),
12322                    },
12323                    payload: serde_json::json!({}),
12324                    trace_id: 16,
12325                    extension_id: Some("ext.shadow.unsafe".to_string()),
12326                },
12327            ),
12328            (
12329                "log",
12330                HostcallRequest {
12331                    call_id: "shadow-unsafe-log".to_string(),
12332                    kind: HostcallKind::Log,
12333                    payload: serde_json::json!({}),
12334                    trace_id: 17,
12335                    extension_id: Some("ext.shadow.unsafe".to_string()),
12336                },
12337            ),
12338        ];
12339
12340        for (case, request) in &requests {
12341            assert!(
12342                !is_shadow_safe_request(request),
12343                "expected non-shadow-safe classification for {case}"
12344            );
12345        }
12346    }
12347
12348    #[test]
12349    fn dual_exec_diff_engine_detects_success_output_mismatch() {
12350        let fast = HostcallOutcome::Success(serde_json::json!({ "value": 1 }));
12351        let compat = HostcallOutcome::Success(serde_json::json!({ "value": 2 }));
12352        let diff = diff_hostcall_outcomes(&fast, &compat).expect("expected diff");
12353        assert_eq!(diff.reason, "success_output_mismatch");
12354        assert_ne!(diff.fast_fingerprint, diff.compat_fingerprint);
12355    }
12356
12357    #[test]
12358    fn dual_exec_forensic_bundle_includes_trace_lane_diff_and_rollback_fields() {
12359        let request = HostcallRequest {
12360            call_id: "forensic-1".to_string(),
12361            kind: HostcallKind::Session {
12362                op: "get_state".to_string(),
12363            },
12364            payload: serde_json::json!({ "op": "get_state" }),
12365            trace_id: 9,
12366            extension_id: Some("ext.forensic".to_string()),
12367        };
12368        let diff = DualExecOutcomeDiff {
12369            reason: "success_output_mismatch",
12370            fast_fingerprint: "success:aaa".to_string(),
12371            compat_fingerprint: "success:bbb".to_string(),
12372        };
12373        let bundle = dual_exec_forensic_bundle(
12374            &request,
12375            &diff,
12376            Some("forced_compat_budget_controller"),
12377            42.0,
12378        );
12379        assert_eq!(
12380            bundle["call_trace"]["call_id"],
12381            Value::String("forensic-1".to_string())
12382        );
12383        assert_eq!(
12384            bundle["lane_decision"]["fast_lane"],
12385            Value::String("fast".to_string())
12386        );
12387        assert_eq!(
12388            bundle["lane_decision"]["compat_lane"],
12389            Value::String("compat_shadow".to_string())
12390        );
12391        assert_eq!(
12392            bundle["diff"]["reason"],
12393            Value::String("success_output_mismatch".to_string())
12394        );
12395        assert_eq!(
12396            bundle["rollback"]["reason"],
12397            Value::String("forced_compat_budget_controller".to_string())
12398        );
12399    }
12400
12401    #[test]
12402    #[allow(clippy::too_many_lines)]
12403    fn dual_exec_divergence_auto_triggers_rollback_kill_switch_state() {
12404        futures::executor::block_on(async {
12405            struct DivergentReadSession {
12406                counter: Arc<Mutex<u64>>,
12407            }
12408
12409            #[async_trait]
12410            impl ExtensionSession for DivergentReadSession {
12411                async fn get_state(&self) -> Value {
12412                    let mut guard = self.counter.lock().expect("counter lock");
12413                    let value = *guard;
12414                    *guard = guard.saturating_add(1);
12415                    drop(guard);
12416                    serde_json::json!({ "seq": value })
12417                }
12418
12419                async fn get_messages(&self) -> Vec<SessionMessage> {
12420                    Vec::new()
12421                }
12422
12423                async fn get_entries(&self) -> Vec<Value> {
12424                    Vec::new()
12425                }
12426
12427                async fn get_branch(&self) -> Vec<Value> {
12428                    Vec::new()
12429                }
12430
12431                async fn set_name(&self, _name: String) -> Result<()> {
12432                    Ok(())
12433                }
12434
12435                async fn append_message(&self, _message: SessionMessage) -> Result<()> {
12436                    Ok(())
12437                }
12438
12439                async fn append_custom_entry(
12440                    &self,
12441                    _custom_type: String,
12442                    _data: Option<Value>,
12443                ) -> Result<()> {
12444                    Ok(())
12445                }
12446
12447                async fn set_model(&self, _provider: String, _model_id: String) -> Result<()> {
12448                    Ok(())
12449                }
12450
12451                async fn get_model(&self) -> (Option<String>, Option<String>) {
12452                    (None, None)
12453                }
12454
12455                async fn set_thinking_level(&self, _level: String) -> Result<()> {
12456                    Ok(())
12457                }
12458
12459                async fn get_thinking_level(&self) -> Option<String> {
12460                    None
12461                }
12462
12463                async fn set_label(
12464                    &self,
12465                    _target_id: String,
12466                    _label: Option<String>,
12467                ) -> Result<()> {
12468                    Ok(())
12469                }
12470            }
12471
12472            let runtime = Rc::new(
12473                PiJsRuntime::with_clock(DeterministicClock::new(0))
12474                    .await
12475                    .expect("runtime"),
12476            );
12477            let session = Arc::new(DivergentReadSession {
12478                counter: Arc::new(Mutex::new(0)),
12479            });
12480            let oracle_config = DualExecOracleConfig {
12481                sample_ppm: DUAL_EXEC_SAMPLE_MODULUS_PPM,
12482                divergence_window: 4,
12483                divergence_budget: 2,
12484                rollback_requests: 24,
12485                overhead_budget_us: u64::MAX,
12486                overhead_backoff_requests: 1,
12487            };
12488            let dispatcher = ExtensionDispatcher::new_with_policy_and_oracle_config(
12489                Rc::clone(&runtime),
12490                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
12491                Arc::new(HttpConnector::with_defaults()),
12492                session,
12493                Arc::new(NullUiHandler),
12494                PathBuf::from("."),
12495                ExtensionPolicy::from_profile(PolicyProfile::Permissive),
12496                oracle_config,
12497            );
12498
12499            for idx in 0..3_u64 {
12500                let request = HostcallRequest {
12501                    call_id: format!("dual-divergence-{idx}"),
12502                    kind: HostcallKind::Session {
12503                        op: "get_state".to_string(),
12504                    },
12505                    payload: serde_json::json!({}),
12506                    trace_id: idx,
12507                    extension_id: Some("ext.shadow.rollback".to_string()),
12508                };
12509                dispatcher.dispatch_and_complete(request).await;
12510            }
12511
12512            let state = dispatcher.dual_exec_state.borrow();
12513            assert!(
12514                state.divergence_total >= 2,
12515                "expected enough divergence samples to trip rollback"
12516            );
12517            assert!(state.rollback_active(), "rollback should be active");
12518            assert!(
12519                state
12520                    .rollback_reason
12521                    .as_deref()
12522                    .is_some_and(|reason| reason.contains("ext.shadow.rollback")),
12523                "rollback reason should include extension scope"
12524            );
12525        });
12526    }
12527
12528    #[test]
12529    fn dual_exec_rollback_forces_dispatch_batch_amac_to_skip_planning() {
12530        futures::executor::block_on(async {
12531            let runtime = Rc::new(
12532                PiJsRuntime::with_clock(DeterministicClock::new(0))
12533                    .await
12534                    .expect("runtime"),
12535            );
12536            let oracle_config = DualExecOracleConfig {
12537                sample_ppm: 0,
12538                divergence_window: 8,
12539                divergence_budget: 2,
12540                rollback_requests: 16,
12541                overhead_budget_us: 1_500,
12542                overhead_backoff_requests: 8,
12543            };
12544            let dispatcher = ExtensionDispatcher::new_with_policy_and_oracle_config(
12545                Rc::clone(&runtime),
12546                Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
12547                Arc::new(HttpConnector::with_defaults()),
12548                Arc::new(NullSession),
12549                Arc::new(NullUiHandler),
12550                PathBuf::from("."),
12551                ExtensionPolicy::from_profile(PolicyProfile::Permissive),
12552                oracle_config,
12553            );
12554
12555            {
12556                let mut amac = dispatcher.amac_executor.borrow_mut();
12557                *amac = AmacBatchExecutor::new(AmacBatchExecutorConfig::new(true, 2, 8));
12558            }
12559
12560            {
12561                let mut detector = dispatcher.regime_detector.borrow_mut();
12562                drive_detector_to_interleaved(&mut detector);
12563            }
12564
12565            let mut baseline = VecDeque::new();
12566            for idx in 0..4_u64 {
12567                baseline.push_back(HostcallRequest {
12568                    call_id: format!("baseline-{idx}"),
12569                    kind: HostcallKind::Session {
12570                        op: "get_state".to_string(),
12571                    },
12572                    payload: serde_json::json!({}),
12573                    trace_id: idx,
12574                    extension_id: Some("ext.roll".to_string()),
12575                });
12576            }
12577            dispatcher.dispatch_batch_amac(baseline).await;
12578            let baseline_decisions = dispatcher
12579                .amac_executor
12580                .borrow()
12581                .telemetry()
12582                .toggle_decisions;
12583            assert!(
12584                baseline_decisions > 0,
12585                "expected AMAC planner to run before rollback activation"
12586            );
12587
12588            {
12589                let mut state = dispatcher.dual_exec_state.borrow_mut();
12590                state.rollback_remaining = 16;
12591                state.rollback_reason =
12592                    Some("dual_exec_divergence_budget_exceeded:test".to_string());
12593            }
12594
12595            let mut rollback_batch = VecDeque::new();
12596            for idx in 0..4_u64 {
12597                rollback_batch.push_back(HostcallRequest {
12598                    call_id: format!("rollback-{idx}"),
12599                    kind: HostcallKind::Session {
12600                        op: "get_state".to_string(),
12601                    },
12602                    payload: serde_json::json!({}),
12603                    trace_id: idx + 100,
12604                    extension_id: Some("ext.roll".to_string()),
12605                });
12606            }
12607            dispatcher.dispatch_batch_amac(rollback_batch).await;
12608
12609            let after_rollback = dispatcher
12610                .amac_executor
12611                .borrow()
12612                .telemetry()
12613                .toggle_decisions;
12614            assert_eq!(
12615                after_rollback, baseline_decisions,
12616                "rollback path should bypass AMAC planning and keep toggle decisions unchanged"
12617            );
12618        });
12619    }
12620
12621    #[test]
12622    fn rollout_mode_controls_amac_planner_activation() {
12623        futures::executor::block_on(async {
12624            let runtime = Rc::new(
12625                PiJsRuntime::with_clock(DeterministicClock::new(0))
12626                    .await
12627                    .expect("runtime"),
12628            );
12629            let dispatcher = build_dispatcher(Rc::clone(&runtime));
12630            {
12631                let mut amac = dispatcher.amac_executor.borrow_mut();
12632                *amac = AmacBatchExecutor::new(AmacBatchExecutorConfig::new(true, 2, 8));
12633            }
12634
12635            let mut sequential_batch = VecDeque::new();
12636            for idx in 0..4_u64 {
12637                sequential_batch.push_back(HostcallRequest {
12638                    call_id: format!("rollout-seq-{idx}"),
12639                    kind: HostcallKind::Session {
12640                        op: "get_state".to_string(),
12641                    },
12642                    payload: serde_json::json!({}),
12643                    trace_id: idx,
12644                    extension_id: Some("ext.rollout.mode".to_string()),
12645                });
12646            }
12647            dispatcher.dispatch_batch_amac(sequential_batch).await;
12648            let decisions_after_seq = dispatcher
12649                .amac_executor
12650                .borrow()
12651                .telemetry()
12652                .toggle_decisions;
12653            assert_eq!(
12654                decisions_after_seq, 0,
12655                "sequential rollout mode should skip AMAC planning"
12656            );
12657
12658            {
12659                let mut detector = dispatcher.regime_detector.borrow_mut();
12660                drive_detector_to_interleaved(&mut detector);
12661            }
12662
12663            let mut interleaved_batch = VecDeque::new();
12664            for idx in 0..4_u64 {
12665                interleaved_batch.push_back(HostcallRequest {
12666                    call_id: format!("rollout-interleaved-{idx}"),
12667                    kind: HostcallKind::Session {
12668                        op: "get_state".to_string(),
12669                    },
12670                    payload: serde_json::json!({}),
12671                    trace_id: idx + 100,
12672                    extension_id: Some("ext.rollout.mode".to_string()),
12673                });
12674            }
12675            dispatcher.dispatch_batch_amac(interleaved_batch).await;
12676            let decisions_after_interleaved = dispatcher
12677                .amac_executor
12678                .borrow()
12679                .telemetry()
12680                .toggle_decisions;
12681            assert!(
12682                decisions_after_interleaved > decisions_after_seq,
12683                "promotion should enable AMAC planning"
12684            );
12685        });
12686    }
12687
12688    #[test]
12689    fn hostcall_io_hint_marks_expected_kinds_as_io_heavy() {
12690        assert_eq!(
12691            hostcall_io_hint(&HostcallKind::Http),
12692            HostcallIoHint::IoHeavy
12693        );
12694        assert_eq!(
12695            hostcall_io_hint(&HostcallKind::Tool {
12696                name: "read".to_string()
12697            }),
12698            HostcallIoHint::IoHeavy
12699        );
12700        assert_eq!(
12701            hostcall_io_hint(&HostcallKind::Session {
12702                op: "append_message".to_string()
12703            }),
12704            HostcallIoHint::IoHeavy
12705        );
12706    }
12707
12708    #[test]
12709    fn hostcall_io_hint_marks_non_io_kinds_as_non_heavy() {
12710        assert_eq!(
12711            hostcall_io_hint(&HostcallKind::Ui {
12712                op: "prompt".to_string()
12713            }),
12714            HostcallIoHint::CpuBound
12715        );
12716        assert_eq!(
12717            hostcall_io_hint(&HostcallKind::Tool {
12718                name: "unknown_tool".to_string()
12719            }),
12720            HostcallIoHint::Unknown
12721        );
12722        assert_eq!(
12723            hostcall_io_hint(&HostcallKind::Session {
12724                op: "get_state".to_string()
12725            }),
12726            HostcallIoHint::Unknown
12727        );
12728    }
12729
12730    #[test]
12731    fn hostcall_io_hint_classifies_edit_bash_and_exec() {
12732        assert_eq!(
12733            hostcall_io_hint(&HostcallKind::Tool {
12734                name: "edit".to_string()
12735            }),
12736            HostcallIoHint::IoHeavy,
12737            "edit tool should be IoHeavy"
12738        );
12739        assert_eq!(
12740            hostcall_io_hint(&HostcallKind::Tool {
12741                name: "bash".to_string()
12742            }),
12743            HostcallIoHint::CpuBound,
12744            "bash tool should be CpuBound"
12745        );
12746        assert_eq!(
12747            hostcall_io_hint(&HostcallKind::Exec {
12748                cmd: "ls".to_string()
12749            }),
12750            HostcallIoHint::CpuBound,
12751            "exec hostcall should be CpuBound"
12752        );
12753    }
12754
12755    #[test]
12756    fn io_uring_bridge_reports_cancellation_when_request_not_pending() {
12757        futures::executor::block_on(async {
12758            let runtime = Rc::new(
12759                PiJsRuntime::with_clock(DeterministicClock::new(0))
12760                    .await
12761                    .expect("runtime"),
12762            );
12763            let dispatcher = build_dispatcher(Rc::clone(&runtime));
12764            let request = HostcallRequest {
12765                call_id: "cancelled-before-io-uring".to_string(),
12766                kind: HostcallKind::Http,
12767                payload: serde_json::json!({
12768                    "url": "https://example.com",
12769                    "method": "GET",
12770                }),
12771                trace_id: 1,
12772                extension_id: Some("ext.cancel".to_string()),
12773            };
12774            let bridge_dispatch = dispatcher.dispatch_hostcall_io_uring(&request).await;
12775            assert_eq!(
12776                bridge_dispatch.state,
12777                IoUringBridgeState::CancelledBeforeDispatch
12778            );
12779            assert_eq!(
12780                bridge_dispatch.fallback_reason,
12781                Some("cancelled_before_io_uring_dispatch")
12782            );
12783            match bridge_dispatch.outcome {
12784                HostcallOutcome::Error { code, message } => {
12785                    assert_eq!(code, "cancelled");
12786                    assert!(
12787                        message.contains("cancelled before io_uring dispatch"),
12788                        "unexpected cancellation message: {message}"
12789                    );
12790                }
12791                other => panic!("expected cancellation error outcome, got {other:?}"),
12792            }
12793        });
12794    }
12795
12796    // ========================================================================
12797    // bd-3ar8v.4.8.21: Protocol error-code taxonomy and validation tests
12798    // ========================================================================
12799
12800    #[test]
12801    fn protocol_error_code_timeout_maps_correctly() {
12802        assert_eq!(protocol_error_code("timeout"), HostCallErrorCode::Timeout);
12803    }
12804
12805    #[test]
12806    fn protocol_error_code_denied_maps_correctly() {
12807        assert_eq!(protocol_error_code("denied"), HostCallErrorCode::Denied);
12808    }
12809
12810    #[test]
12811    fn protocol_error_code_io_maps_correctly() {
12812        assert_eq!(protocol_error_code("io"), HostCallErrorCode::Io);
12813    }
12814
12815    #[test]
12816    fn protocol_error_code_tool_error_maps_to_io() {
12817        assert_eq!(protocol_error_code("tool_error"), HostCallErrorCode::Io);
12818    }
12819
12820    #[test]
12821    fn protocol_error_code_invalid_request_maps_correctly() {
12822        assert_eq!(
12823            protocol_error_code("invalid_request"),
12824            HostCallErrorCode::InvalidRequest
12825        );
12826    }
12827
12828    #[test]
12829    fn protocol_error_code_completely_unknown_maps_to_internal() {
12830        assert_eq!(
12831            protocol_error_code("completely_unknown"),
12832            HostCallErrorCode::Internal
12833        );
12834    }
12835
12836    #[test]
12837    fn protocol_error_code_empty_string_maps_to_internal() {
12838        assert_eq!(protocol_error_code(""), HostCallErrorCode::Internal);
12839    }
12840
12841    #[test]
12842    fn protocol_error_code_whitespace_only_maps_to_internal() {
12843        assert_eq!(protocol_error_code("   "), HostCallErrorCode::Internal);
12844    }
12845
12846    #[test]
12847    fn protocol_error_code_case_insensitive_timeout() {
12848        assert_eq!(protocol_error_code("TIMEOUT"), HostCallErrorCode::Timeout);
12849        assert_eq!(protocol_error_code("Timeout"), HostCallErrorCode::Timeout);
12850        assert_eq!(protocol_error_code("TimeOut"), HostCallErrorCode::Timeout);
12851    }
12852
12853    #[test]
12854    fn protocol_error_code_case_insensitive_denied() {
12855        assert_eq!(protocol_error_code("DENIED"), HostCallErrorCode::Denied);
12856        assert_eq!(protocol_error_code("Denied"), HostCallErrorCode::Denied);
12857    }
12858
12859    #[test]
12860    fn protocol_error_code_case_insensitive_io() {
12861        assert_eq!(protocol_error_code("IO"), HostCallErrorCode::Io);
12862        assert_eq!(protocol_error_code("Io"), HostCallErrorCode::Io);
12863        assert_eq!(protocol_error_code("TOOL_ERROR"), HostCallErrorCode::Io);
12864        assert_eq!(protocol_error_code("Tool_Error"), HostCallErrorCode::Io);
12865    }
12866
12867    #[test]
12868    fn protocol_error_code_case_insensitive_invalid_request() {
12869        assert_eq!(
12870            protocol_error_code("INVALID_REQUEST"),
12871            HostCallErrorCode::InvalidRequest
12872        );
12873        assert_eq!(
12874            protocol_error_code("Invalid_Request"),
12875            HostCallErrorCode::InvalidRequest
12876        );
12877    }
12878
12879    #[test]
12880    fn protocol_error_code_trims_whitespace() {
12881        assert_eq!(
12882            protocol_error_code("  timeout  "),
12883            HostCallErrorCode::Timeout
12884        );
12885        assert_eq!(protocol_error_code("\tdenied\n"), HostCallErrorCode::Denied);
12886    }
12887
12888    #[test]
12889    fn parse_protocol_hostcall_method_all_known_methods() {
12890        assert_eq!(
12891            parse_protocol_hostcall_method("tool"),
12892            Some(ProtocolHostcallMethod::Tool)
12893        );
12894        assert_eq!(
12895            parse_protocol_hostcall_method("exec"),
12896            Some(ProtocolHostcallMethod::Exec)
12897        );
12898        assert_eq!(
12899            parse_protocol_hostcall_method("http"),
12900            Some(ProtocolHostcallMethod::Http)
12901        );
12902        assert_eq!(
12903            parse_protocol_hostcall_method("session"),
12904            Some(ProtocolHostcallMethod::Session)
12905        );
12906        assert_eq!(
12907            parse_protocol_hostcall_method("ui"),
12908            Some(ProtocolHostcallMethod::Ui)
12909        );
12910        assert_eq!(
12911            parse_protocol_hostcall_method("events"),
12912            Some(ProtocolHostcallMethod::Events)
12913        );
12914        assert_eq!(
12915            parse_protocol_hostcall_method("log"),
12916            Some(ProtocolHostcallMethod::Log)
12917        );
12918    }
12919
12920    #[test]
12921    fn parse_protocol_hostcall_method_case_insensitive() {
12922        assert_eq!(
12923            parse_protocol_hostcall_method("TOOL"),
12924            Some(ProtocolHostcallMethod::Tool)
12925        );
12926        assert_eq!(
12927            parse_protocol_hostcall_method("Tool"),
12928            Some(ProtocolHostcallMethod::Tool)
12929        );
12930        assert_eq!(
12931            parse_protocol_hostcall_method("SESSION"),
12932            Some(ProtocolHostcallMethod::Session)
12933        );
12934        assert_eq!(
12935            parse_protocol_hostcall_method("Events"),
12936            Some(ProtocolHostcallMethod::Events)
12937        );
12938    }
12939
12940    #[test]
12941    fn parse_protocol_hostcall_method_trims_whitespace() {
12942        assert_eq!(
12943            parse_protocol_hostcall_method("  tool  "),
12944            Some(ProtocolHostcallMethod::Tool)
12945        );
12946        assert_eq!(
12947            parse_protocol_hostcall_method("\texec\n"),
12948            Some(ProtocolHostcallMethod::Exec)
12949        );
12950    }
12951
12952    #[test]
12953    fn parse_protocol_hostcall_method_rejects_unknown() {
12954        assert_eq!(parse_protocol_hostcall_method("unknown"), None);
12955        assert_eq!(parse_protocol_hostcall_method("foobar"), None);
12956        assert_eq!(parse_protocol_hostcall_method("tools"), None);
12957    }
12958
12959    #[test]
12960    fn parse_protocol_hostcall_method_rejects_empty() {
12961        assert_eq!(parse_protocol_hostcall_method(""), None);
12962        assert_eq!(parse_protocol_hostcall_method("   "), None);
12963    }
12964
12965    #[test]
12966    fn protocol_normalize_output_preserves_objects() {
12967        let obj = serde_json::json!({"key": "value", "nested": {"a": 1}});
12968        let result = protocol_normalize_output(obj.clone());
12969        assert_eq!(result, obj);
12970    }
12971
12972    #[test]
12973    fn protocol_normalize_output_wraps_string() {
12974        let val = serde_json::json!("hello");
12975        let result = protocol_normalize_output(val);
12976        assert_eq!(result, serde_json::json!({"value": "hello"}));
12977    }
12978
12979    #[test]
12980    fn protocol_normalize_output_wraps_number() {
12981        let val = serde_json::json!(42);
12982        let result = protocol_normalize_output(val);
12983        assert_eq!(result, serde_json::json!({"value": 42}));
12984    }
12985
12986    #[test]
12987    fn protocol_normalize_output_wraps_bool() {
12988        let val = serde_json::json!(true);
12989        let result = protocol_normalize_output(val);
12990        assert_eq!(result, serde_json::json!({"value": true}));
12991    }
12992
12993    #[test]
12994    fn protocol_normalize_output_wraps_null() {
12995        let val = Value::Null;
12996        let result = protocol_normalize_output(val);
12997        assert_eq!(result, serde_json::json!({"value": null}));
12998    }
12999
13000    #[test]
13001    fn protocol_normalize_output_wraps_array() {
13002        let val = serde_json::json!([1, 2, 3]);
13003        let result = protocol_normalize_output(val);
13004        assert_eq!(result, serde_json::json!({"value": [1, 2, 3]}));
13005    }
13006
13007    #[test]
13008    fn protocol_normalize_output_preserves_empty_object() {
13009        let val = serde_json::json!({});
13010        let result = protocol_normalize_output(val.clone());
13011        assert_eq!(result, val);
13012    }
13013
13014    #[test]
13015    fn protocol_error_fallback_reason_denied() {
13016        assert_eq!(
13017            protocol_error_fallback_reason("tool", "denied"),
13018            "policy_denied"
13019        );
13020        assert_eq!(
13021            protocol_error_fallback_reason("exec", "DENIED"),
13022            "policy_denied"
13023        );
13024    }
13025
13026    #[test]
13027    fn protocol_error_fallback_reason_timeout() {
13028        assert_eq!(
13029            protocol_error_fallback_reason("tool", "timeout"),
13030            "handler_timeout"
13031        );
13032    }
13033
13034    #[test]
13035    fn protocol_error_fallback_reason_io() {
13036        assert_eq!(
13037            protocol_error_fallback_reason("tool", "io"),
13038            "handler_error"
13039        );
13040        assert_eq!(
13041            protocol_error_fallback_reason("exec", "tool_error"),
13042            "handler_error"
13043        );
13044    }
13045
13046    #[test]
13047    fn protocol_error_fallback_reason_invalid_request_known_method() {
13048        assert_eq!(
13049            protocol_error_fallback_reason("tool", "invalid_request"),
13050            "schema_validation_failed"
13051        );
13052        assert_eq!(
13053            protocol_error_fallback_reason("session", "invalid_request"),
13054            "schema_validation_failed"
13055        );
13056    }
13057
13058    #[test]
13059    fn protocol_error_fallback_reason_invalid_request_unknown_method() {
13060        assert_eq!(
13061            protocol_error_fallback_reason("nonexistent", "invalid_request"),
13062            "unsupported_method_fallback"
13063        );
13064    }
13065
13066    #[test]
13067    fn protocol_error_fallback_reason_unknown_code() {
13068        assert_eq!(
13069            protocol_error_fallback_reason("tool", "something_else"),
13070            "runtime_internal_error"
13071        );
13072        assert_eq!(
13073            protocol_error_fallback_reason("tool", ""),
13074            "runtime_internal_error"
13075        );
13076    }
13077
13078    #[test]
13079    fn protocol_error_details_structure_complete() {
13080        let payload = HostCallPayload {
13081            call_id: "test-call-1".to_string(),
13082            capability: "tool".to_string(),
13083            method: "tool".to_string(),
13084            params: serde_json::json!({"name": "read", "input": {"path": "/tmp/test"}}),
13085            timeout_ms: None,
13086            cancel_token: None,
13087            context: None,
13088        };
13089
13090        let details = protocol_error_details(&payload, "invalid_request", "Tool not found");
13091
13092        // Verify top-level structure
13093        assert!(details.get("dispatcherDecisionTrace").is_some());
13094        assert!(details.get("schemaDiff").is_some());
13095        assert!(details.get("extensionInput").is_some());
13096        assert!(details.get("extensionOutput").is_some());
13097
13098        // Verify dispatcher decision trace
13099        let trace = &details["dispatcherDecisionTrace"];
13100        assert_eq!(trace["selectedRuntime"], "rust-extension-dispatcher");
13101        assert_eq!(trace["schemaVersion"], PROTOCOL_VERSION);
13102        assert_eq!(trace["method"], "tool");
13103        assert_eq!(trace["capability"], "tool");
13104        assert_eq!(trace["fallbackReason"], "schema_validation_failed");
13105
13106        // Verify schema diff has sorted keys
13107        let observed_keys = details["schemaDiff"]["observedParamKeys"]
13108            .as_array()
13109            .expect("observedParamKeys must be array");
13110        let keys: Vec<&str> = observed_keys.iter().filter_map(|v| v.as_str()).collect();
13111        assert_eq!(keys, vec!["input", "name"]);
13112
13113        // Verify extension input
13114        assert_eq!(details["extensionInput"]["callId"], "test-call-1");
13115        assert_eq!(details["extensionInput"]["capability"], "tool");
13116        assert_eq!(details["extensionInput"]["method"], "tool");
13117
13118        // Verify extension output
13119        assert_eq!(details["extensionOutput"]["code"], "invalid_request");
13120        assert_eq!(details["extensionOutput"]["message"], "Tool not found");
13121    }
13122
13123    #[test]
13124    fn protocol_error_details_non_object_params_yields_empty_keys() {
13125        let payload = HostCallPayload {
13126            call_id: "test-call-2".to_string(),
13127            capability: "exec".to_string(),
13128            method: "exec".to_string(),
13129            params: serde_json::json!("not an object"),
13130            timeout_ms: None,
13131            cancel_token: None,
13132            context: None,
13133        };
13134
13135        let details = protocol_error_details(&payload, "io", "exec failed");
13136        let observed_keys = details["schemaDiff"]["observedParamKeys"]
13137            .as_array()
13138            .expect("must be array");
13139        assert!(observed_keys.is_empty());
13140        assert_eq!(
13141            details["dispatcherDecisionTrace"]["fallbackReason"],
13142            "handler_error"
13143        );
13144    }
13145
13146    #[test]
13147    fn protocol_hostcall_op_extracts_from_op_key() {
13148        let params = serde_json::json!({"op": "getState"});
13149        assert_eq!(protocol_hostcall_op(&params), Some("getState"));
13150    }
13151
13152    #[test]
13153    fn protocol_hostcall_op_extracts_from_method_key() {
13154        let params = serde_json::json!({"method": "setModel"});
13155        assert_eq!(protocol_hostcall_op(&params), Some("setModel"));
13156    }
13157
13158    #[test]
13159    fn protocol_hostcall_op_extracts_from_name_key() {
13160        let params = serde_json::json!({"name": "read"});
13161        assert_eq!(protocol_hostcall_op(&params), Some("read"));
13162    }
13163
13164    #[test]
13165    fn protocol_hostcall_op_prefers_op_over_method() {
13166        let params = serde_json::json!({"op": "first", "method": "second"});
13167        assert_eq!(protocol_hostcall_op(&params), Some("first"));
13168    }
13169
13170    #[test]
13171    fn protocol_hostcall_op_prefers_method_over_name() {
13172        let params = serde_json::json!({"method": "first", "name": "second"});
13173        assert_eq!(protocol_hostcall_op(&params), Some("first"));
13174    }
13175
13176    #[test]
13177    fn protocol_hostcall_op_returns_none_for_empty_params() {
13178        let params = serde_json::json!({});
13179        assert_eq!(protocol_hostcall_op(&params), None);
13180    }
13181
13182    #[test]
13183    fn protocol_hostcall_op_returns_none_for_empty_string_value() {
13184        let params = serde_json::json!({"op": ""});
13185        assert_eq!(protocol_hostcall_op(&params), None);
13186    }
13187
13188    #[test]
13189    fn protocol_hostcall_op_returns_none_for_whitespace_only_value() {
13190        let params = serde_json::json!({"op": "   "});
13191        assert_eq!(protocol_hostcall_op(&params), None);
13192    }
13193
13194    #[test]
13195    fn protocol_hostcall_op_trims_result() {
13196        let params = serde_json::json!({"op": "  getState  "});
13197        assert_eq!(protocol_hostcall_op(&params), Some("getState"));
13198    }
13199
13200    #[test]
13201    fn protocol_hostcall_op_returns_none_for_non_string_value() {
13202        let params = serde_json::json!({"op": 42});
13203        assert_eq!(protocol_hostcall_op(&params), None);
13204    }
13205
13206    #[test]
13207    fn hostcall_outcome_to_protocol_result_success_normalizes_output() {
13208        let result = hostcall_outcome_to_protocol_result(
13209            "call-s1",
13210            HostcallOutcome::Success(serde_json::json!({"result": "ok"})),
13211        );
13212        assert_eq!(result.call_id, "call-s1");
13213        assert!(!result.is_error);
13214        assert!(result.error.is_none());
13215        assert!(result.chunk.is_none());
13216        assert_eq!(result.output, serde_json::json!({"result": "ok"}));
13217    }
13218
13219    #[test]
13220    fn hostcall_outcome_to_protocol_result_success_wraps_plain_string() {
13221        let result = hostcall_outcome_to_protocol_result(
13222            "call-s2",
13223            HostcallOutcome::Success(serde_json::json!("plain string")),
13224        );
13225        assert_eq!(result.output, serde_json::json!({"value": "plain string"}));
13226    }
13227
13228    #[test]
13229    fn hostcall_outcome_to_protocol_result_error_maps_code() {
13230        let result = hostcall_outcome_to_protocol_result(
13231            "call-e1",
13232            HostcallOutcome::Error {
13233                code: "denied".to_string(),
13234                message: "not allowed".to_string(),
13235            },
13236        );
13237        assert_eq!(result.call_id, "call-e1");
13238        assert!(result.is_error);
13239        let err = result.error.as_ref().expect("error payload");
13240        assert_eq!(err.code, HostCallErrorCode::Denied);
13241        assert_eq!(err.message, "not allowed");
13242        assert!(err.details.is_none());
13243        assert!(result.output.is_object());
13244    }
13245
13246    #[test]
13247    fn hostcall_outcome_to_protocol_result_error_unknown_code_maps_internal() {
13248        let result = hostcall_outcome_to_protocol_result(
13249            "call-e2",
13250            HostcallOutcome::Error {
13251                code: "mystery_error".to_string(),
13252                message: "something broke".to_string(),
13253            },
13254        );
13255        let err = result.error.as_ref().expect("error payload");
13256        assert_eq!(err.code, HostCallErrorCode::Internal);
13257    }
13258
13259    #[test]
13260    fn hostcall_outcome_to_protocol_result_stream_partial_chunk() {
13261        let result = hostcall_outcome_to_protocol_result(
13262            "call-sc1",
13263            HostcallOutcome::StreamChunk {
13264                sequence: 5,
13265                chunk: serde_json::json!({"data": "partial"}),
13266                is_final: false,
13267            },
13268        );
13269        assert_eq!(result.call_id, "call-sc1");
13270        assert!(!result.is_error);
13271        assert!(result.error.is_none());
13272        let chunk = result.chunk.as_ref().expect("chunk metadata");
13273        assert_eq!(chunk.index, 5);
13274        assert!(!chunk.is_last);
13275        assert_eq!(result.output["sequence"], 5);
13276        assert_eq!(result.output["isFinal"], false);
13277    }
13278
13279    #[test]
13280    fn hostcall_outcome_to_protocol_result_stream_final_chunk() {
13281        let result = hostcall_outcome_to_protocol_result(
13282            "call-sc2",
13283            HostcallOutcome::StreamChunk {
13284                sequence: 10,
13285                chunk: serde_json::json!(null),
13286                is_final: true,
13287            },
13288        );
13289        let chunk = result.chunk.as_ref().expect("chunk metadata");
13290        assert!(chunk.is_last);
13291        assert_eq!(result.output["isFinal"], true);
13292    }
13293
13294    #[test]
13295    fn hostcall_outcome_to_protocol_result_with_trace_error_includes_details() {
13296        let payload = HostCallPayload {
13297            call_id: "call-trace-1".to_string(),
13298            capability: "tool".to_string(),
13299            method: "tool".to_string(),
13300            params: serde_json::json!({"name": "read"}),
13301            timeout_ms: None,
13302            cancel_token: None,
13303            context: None,
13304        };
13305
13306        let result = hostcall_outcome_to_protocol_result_with_trace(
13307            &payload,
13308            HostcallOutcome::Error {
13309                code: "timeout".to_string(),
13310                message: "operation timed out".to_string(),
13311            },
13312        );
13313
13314        assert!(result.is_error);
13315        let err = result.error.as_ref().expect("error");
13316        assert_eq!(err.code, HostCallErrorCode::Timeout);
13317        assert_eq!(err.message, "operation timed out");
13318
13319        // With-trace variant must include details
13320        let details = err.details.as_ref().expect("details must be present");
13321        assert!(details.get("dispatcherDecisionTrace").is_some());
13322        assert_eq!(
13323            details["dispatcherDecisionTrace"]["fallbackReason"],
13324            "handler_timeout"
13325        );
13326    }
13327
13328    #[test]
13329    fn hostcall_outcome_to_protocol_result_with_trace_success_no_details() {
13330        let payload = HostCallPayload {
13331            call_id: "call-trace-2".to_string(),
13332            capability: "tool".to_string(),
13333            method: "tool".to_string(),
13334            params: serde_json::json!({"name": "read"}),
13335            timeout_ms: None,
13336            cancel_token: None,
13337            context: None,
13338        };
13339
13340        let result = hostcall_outcome_to_protocol_result_with_trace(
13341            &payload,
13342            HostcallOutcome::Success(serde_json::json!({"content": "file data"})),
13343        );
13344
13345        assert!(!result.is_error);
13346        assert!(result.error.is_none());
13347        assert_eq!(result.output["content"], "file data");
13348    }
13349
13350    // ── Property tests ──
13351
13352    mod proptest_dispatcher {
13353        use super::*;
13354        use proptest::prelude::*;
13355
13356        proptest! {
13357            #[test]
13358            fn shannon_entropy_nonnegative(bytes in prop::collection::vec(any::<u8>(), 0..200)) {
13359                let entropy = shannon_entropy_bytes(&bytes);
13360                assert!(
13361                    entropy >= 0.0,
13362                    "entropy must be non-negative, got {entropy}"
13363                );
13364            }
13365
13366            #[test]
13367            fn shannon_entropy_bounded_by_log2_256(
13368                bytes in prop::collection::vec(any::<u8>(), 1..200),
13369            ) {
13370                let entropy = shannon_entropy_bytes(&bytes);
13371                assert!(
13372                    entropy <= 8.0 + f64::EPSILON,
13373                    "entropy must be <= 8.0 (log2(256)), got {entropy}"
13374                );
13375            }
13376
13377            #[test]
13378            fn shannon_entropy_empty_is_zero(_dummy in Just(())) {
13379                assert!(
13380                    (shannon_entropy_bytes(&[]) - 0.0).abs() < f64::EPSILON,
13381                    "entropy of empty input must be 0.0"
13382                );
13383            }
13384
13385            #[test]
13386            fn shannon_entropy_single_byte_is_zero(byte in any::<u8>()) {
13387                let entropy = shannon_entropy_bytes(&[byte]);
13388                assert!(
13389                    entropy.abs() < f64::EPSILON,
13390                    "entropy of single byte must be 0.0, got {entropy}"
13391                );
13392            }
13393
13394            #[test]
13395            fn shannon_entropy_uniform_is_maximal(
13396                len in 256..512usize,
13397            ) {
13398                // Construct input with every byte value appearing equally
13399                #[allow(clippy::cast_possible_truncation)]
13400                let bytes: Vec<u8> = (0..len).map(|i| (i % 256) as u8).collect();
13401                let entropy = shannon_entropy_bytes(&bytes);
13402                // Should be close to 8.0 (log2(256))
13403                assert!(
13404                    entropy > 7.9,
13405                    "uniform distribution entropy should be near 8.0, got {entropy}"
13406                );
13407            }
13408
13409            #[test]
13410            fn llc_miss_proxy_bounded(
13411                total_depth in 0..10_000usize,
13412                overflow_depth in 0..10_000usize,
13413                rejected_total in 0..100_000u64,
13414            ) {
13415                let proxy = llc_miss_proxy(total_depth, overflow_depth, rejected_total);
13416                assert!(
13417                    (0.0..=1.0).contains(&proxy),
13418                    "llc_miss_proxy must be in [0.0, 1.0], got {proxy}"
13419                );
13420            }
13421
13422            #[test]
13423            fn llc_miss_proxy_zero_on_empty(_dummy in Just(())) {
13424                let proxy = llc_miss_proxy(0, 0, 0);
13425                assert!(
13426                    proxy.abs() < f64::EPSILON,
13427                    "llc_miss_proxy(0, 0, 0) must be 0.0"
13428                );
13429            }
13430
13431            #[test]
13432            fn normalized_shadow_op_idempotent(op in "[a-zA-Z_]{1,20}") {
13433                let once = normalized_shadow_op(&op);
13434                let twice = normalized_shadow_op(&once);
13435                assert!(
13436                    once == twice,
13437                    "normalized_shadow_op must be idempotent: '{once}' vs '{twice}'"
13438                );
13439            }
13440
13441            #[test]
13442            fn normalized_shadow_op_case_insensitive(op in "[a-zA-Z]{1,20}") {
13443                let lower = normalized_shadow_op(&op.to_lowercase());
13444                let upper = normalized_shadow_op(&op.to_uppercase());
13445                assert!(
13446                    lower == upper,
13447                    "normalized_shadow_op must be case-insensitive: '{lower}' vs '{upper}'"
13448                );
13449            }
13450
13451            #[test]
13452            fn shadow_safe_session_op_case_insensitive(
13453                op in prop::sample::select(vec![
13454                    "getState".to_string(),
13455                    "GETSTATE".to_string(),
13456                    "get_state".to_string(),
13457                    "GET_STATE".to_string(),
13458                    "getMessages".to_string(),
13459                    "GET_MESSAGES".to_string(),
13460                ]),
13461            ) {
13462                assert!(
13463                    shadow_safe_session_op(&op),
13464                    "'{op}' should be recognized as safe session op"
13465                );
13466            }
13467
13468            #[test]
13469            fn shadow_safe_tool_case_insensitive(
13470                name in prop::sample::select(vec![
13471                    "Read".to_string(),
13472                    "READ".to_string(),
13473                    "read".to_string(),
13474                    "Grep".to_string(),
13475                    "GREP".to_string(),
13476                ]),
13477            ) {
13478                assert!(
13479                    shadow_safe_tool(&name),
13480                    "'{name}' should be safe tool"
13481                );
13482            }
13483
13484            #[test]
13485            fn usize_to_f64_monotonic(a in 0..u32::MAX as usize, b in 0..u32::MAX as usize) {
13486                let fa = usize_to_f64(a);
13487                let fb = usize_to_f64(b);
13488                if a <= b {
13489                    assert!(
13490                        fa <= fb,
13491                        "usize_to_f64 must be monotonic: {a} → {fa}, {b} → {fb}"
13492                    );
13493                }
13494            }
13495        }
13496    }
13497}