1use 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
43pub struct ExtensionDispatcher<C: SchedulerClock = WallClock> {
45 runtime: Rc<dyn ExtensionDispatcherRuntime<C>>,
47 tool_registry: Arc<ToolRegistry>,
49 http_connector: Arc<HttpConnector>,
51 session: Arc<dyn ExtensionSession + Send + Sync>,
53 ui_handler: Arc<dyn ExtensionUiHandler + Send + Sync>,
55 cwd: PathBuf,
57 policy: ExtensionPolicy,
59 snapshot: PolicySnapshot,
61 snapshot_version: String,
63 dual_exec_config: DualExecOracleConfig,
65 dual_exec_state: RefCell<DualExecOracleState>,
67 io_uring_lane_config: IoUringLanePolicyConfig,
69 io_uring_force_compat: bool,
71 regime_detector: RefCell<RegimeShiftDetector>,
73 amac_executor: RefCell<AmacBatchExecutor>,
75}
76
77pub 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 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 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 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 #[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 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 #[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 #[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 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 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 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 #[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 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 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 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 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 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 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 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)] 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); 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 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#[async_trait]
3595pub trait HostcallHandler: Send + Sync {
3596 async fn handle(&self, params: serde_json::Value) -> HostcallOutcome;
3598
3599 fn capability(&self) -> &'static str;
3601}
3602
3603#[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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 #[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 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 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 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 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 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)] 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 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 #[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 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 #[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 #[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 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 #[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 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 #[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 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 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 #[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 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 #[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 #[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 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 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 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 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 #[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 #[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 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 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 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 #[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 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 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 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 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 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 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 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 #[test]
10772 fn protocol_hostcall_op_extracts_op_field() {
10773 let params = serde_json::json!({ "op": "get_state" });
10774 assert_eq!(protocol_hostcall_op(¶ms), 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(¶ms), 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(¶ms), 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(¶ms), 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(¶ms), 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(¶ms), 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 #[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 #[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 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 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 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 assert_eq!(details["extensionInput"]["callId"], "test-call-1");
13115 assert_eq!(details["extensionInput"]["capability"], "tool");
13116 assert_eq!(details["extensionInput"]["method"], "tool");
13117
13118 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(¶ms), 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(¶ms), 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(¶ms), 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(¶ms), 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(¶ms), 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(¶ms), 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(¶ms), 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(¶ms), 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(¶ms), 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(¶ms), 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 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 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 #[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 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}