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::time::{sleep, wall_now};
18use async_trait::async_trait;
19use serde_json::Value;
20use sha2::Digest as _;
21
22use crate::connectors::{Connector, http::HttpConnector};
23use crate::error::Result;
24use crate::extensions::EXTENSION_EVENT_TIMEOUT_MS;
25use crate::extensions::{
26 DangerousCommandClass, ExecMediationResult, ExtensionBody, ExtensionMessage, ExtensionPolicy,
27 ExtensionSession, ExtensionUiRequest, ExtensionUiResponse, HostCallError, HostCallErrorCode,
28 HostCallPayload, HostResultPayload, HostStreamChunk, PROTOCOL_VERSION, PolicyCheck,
29 PolicyDecision, PolicyProfile, PolicySnapshot, classify_ui_hostcall_error,
30 evaluate_exec_mediation, hash_canonical_json, required_capability_for_host_call_static,
31 ui_response_value_for_op, validate_host_call,
32};
33use crate::extensions_js::{HostcallKind, HostcallRequest, PiJsRuntime, js_to_json, json_to_js};
34use crate::hostcall_amac::{AmacBatchExecutor, AmacBatchExecutorConfig};
35use crate::hostcall_io_uring_lane::{
36 HostcallCapabilityClass, HostcallDispatchLane, HostcallIoHint, IoUringLaneDecisionInput,
37 IoUringLanePolicyConfig, decide_io_uring_lane,
38};
39use crate::scheduler::{Clock as SchedulerClock, HostcallOutcome, WallClock};
40use crate::tools::ToolRegistry;
41
42struct CancelGuard(Arc<std::sync::atomic::AtomicBool>);
43impl Drop for CancelGuard {
44 fn drop(&mut self) {
45 self.0.store(true, std::sync::atomic::Ordering::SeqCst);
46 }
47}
48
49fn extension_wait_now() -> asupersync::types::Time {
50 Cx::current()
51 .and_then(|cx| cx.timer_driver())
52 .map_or_else(wall_now, |driver| driver.now())
53}
54
55fn extension_wait_sleep(duration: Duration) -> asupersync::time::Sleep {
56 sleep(extension_wait_now(), duration)
57}
58
59pub struct ExtensionDispatcher<C: SchedulerClock = WallClock> {
61 runtime: Rc<dyn ExtensionDispatcherRuntime<C>>,
63 tool_registry: Arc<ToolRegistry>,
65 http_connector: Arc<HttpConnector>,
67 session: Arc<dyn ExtensionSession + Send + Sync>,
69 ui_handler: Arc<dyn ExtensionUiHandler + Send + Sync>,
71 cwd: PathBuf,
73 policy: ExtensionPolicy,
75 snapshot: PolicySnapshot,
77 snapshot_version: String,
79 dual_exec_config: DualExecOracleConfig,
81 dual_exec_state: RefCell<DualExecOracleState>,
83 io_uring_lane_config: IoUringLanePolicyConfig,
85 io_uring_force_compat: bool,
87 regime_detector: RefCell<RegimeShiftDetector>,
89 amac_executor: RefCell<AmacBatchExecutor>,
91}
92
93pub trait ExtensionDispatcherRuntime<C: SchedulerClock>: 'static {
95 fn as_js_runtime(&self) -> &PiJsRuntime<C>;
96}
97
98impl<C: SchedulerClock + 'static> ExtensionDispatcherRuntime<C> for PiJsRuntime<C> {
99 #[allow(clippy::use_self)]
100 fn as_js_runtime(&self) -> &PiJsRuntime<C> {
101 self
102 }
103}
104
105fn protocol_hostcall_op(params: &Value) -> Option<&str> {
106 params
107 .get("op")
108 .or_else(|| params.get("method"))
109 .or_else(|| params.get("name"))
110 .and_then(Value::as_str)
111 .map(str::trim)
112 .filter(|value| !value.is_empty())
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116enum ProtocolHostcallMethod {
117 Tool,
118 Exec,
119 Http,
120 Session,
121 Ui,
122 Events,
123 Log,
124}
125
126fn parse_protocol_hostcall_method(method: &str) -> Option<ProtocolHostcallMethod> {
127 let method = method.trim();
128 if method.is_empty() {
129 return None;
130 }
131
132 if method.eq_ignore_ascii_case("tool") {
133 Some(ProtocolHostcallMethod::Tool)
134 } else if method.eq_ignore_ascii_case("exec") {
135 Some(ProtocolHostcallMethod::Exec)
136 } else if method.eq_ignore_ascii_case("http") {
137 Some(ProtocolHostcallMethod::Http)
138 } else if method.eq_ignore_ascii_case("session") {
139 Some(ProtocolHostcallMethod::Session)
140 } else if method.eq_ignore_ascii_case("ui") {
141 Some(ProtocolHostcallMethod::Ui)
142 } else if method.eq_ignore_ascii_case("events") {
143 Some(ProtocolHostcallMethod::Events)
144 } else if method.eq_ignore_ascii_case("log") {
145 Some(ProtocolHostcallMethod::Log)
146 } else {
147 None
148 }
149}
150
151fn protocol_normalize_output(value: Value) -> Value {
152 if value.is_object() {
153 value
154 } else {
155 serde_json::json!({ "value": value })
156 }
157}
158
159fn policy_snapshot_version(policy: &ExtensionPolicy) -> String {
160 let mut hasher = sha2::Sha256::new();
161 match serde_json::to_value(policy) {
162 Ok(value) => hash_canonical_json(&value, &mut hasher),
163 Err(err) => hasher.update(err.to_string().as_bytes()),
164 }
165 format!("{:x}", hasher.finalize())
166}
167
168fn policy_lookup_path(capability: &str) -> &'static str {
169 let capability = capability.trim();
170 if capability.eq_ignore_ascii_case("read")
171 || capability.eq_ignore_ascii_case("write")
172 || capability.eq_ignore_ascii_case("exec")
173 || capability.eq_ignore_ascii_case("env")
174 || capability.eq_ignore_ascii_case("http")
175 || capability.eq_ignore_ascii_case("session")
176 || capability.eq_ignore_ascii_case("events")
177 || capability.eq_ignore_ascii_case("ui")
178 || capability.eq_ignore_ascii_case("log")
179 || capability.eq_ignore_ascii_case("tool")
180 {
181 "policy_snapshot_table"
182 } else {
183 "policy_snapshot_fallback"
184 }
185}
186
187fn protocol_error_code(code: &str) -> HostCallErrorCode {
188 let code = code.trim();
189 if code.eq_ignore_ascii_case("timeout") {
190 HostCallErrorCode::Timeout
191 } else if code.eq_ignore_ascii_case("denied") {
192 HostCallErrorCode::Denied
193 } else if code.eq_ignore_ascii_case("io") || code.eq_ignore_ascii_case("tool_error") {
194 HostCallErrorCode::Io
195 } else if code.eq_ignore_ascii_case("invalid_request") {
196 HostCallErrorCode::InvalidRequest
197 } else {
198 HostCallErrorCode::Internal
199 }
200}
201
202fn protocol_error_fallback_reason(method: &str, code: &str) -> &'static str {
203 let code = code.trim();
204 if code.eq_ignore_ascii_case("denied") {
205 "policy_denied"
206 } else if code.eq_ignore_ascii_case("timeout") {
207 "handler_timeout"
208 } else if code.eq_ignore_ascii_case("io") || code.eq_ignore_ascii_case("tool_error") {
209 "handler_error"
210 } else if code.eq_ignore_ascii_case("invalid_request") {
211 if parse_protocol_hostcall_method(method).is_some() {
212 "schema_validation_failed"
213 } else {
214 "unsupported_method_fallback"
215 }
216 } else {
217 "runtime_internal_error"
218 }
219}
220
221fn protocol_error_details(payload: &HostCallPayload, code: &str, message: &str) -> Value {
222 let observed_param_keys = payload
223 .params
224 .as_object()
225 .map(|object| {
226 let mut keys = object.keys().cloned().collect::<Vec<_>>();
227 keys.sort();
228 keys
229 })
230 .unwrap_or_default();
231
232 serde_json::json!({
233 "dispatcherDecisionTrace": {
234 "selectedRuntime": "rust-extension-dispatcher",
235 "schemaPath": "ExtensionBody::HostCall/HostCallPayload",
236 "schemaVersion": PROTOCOL_VERSION,
237 "method": payload.method,
238 "capability": payload.capability,
239 "fallbackReason": protocol_error_fallback_reason(&payload.method, code),
240 },
241 "schemaDiff": {
242 "observedParamKeys": observed_param_keys,
243 },
244 "extensionInput": {
245 "callId": payload.call_id,
246 "capability": payload.capability,
247 "method": payload.method,
248 "params": payload.params,
249 },
250 "extensionOutput": {
251 "code": code,
252 "message": message,
253 },
254 })
255}
256
257fn hostcall_outcome_to_protocol_result(
258 call_id: &str,
259 outcome: HostcallOutcome,
260) -> HostResultPayload {
261 match outcome {
262 HostcallOutcome::Success(output) => HostResultPayload {
263 call_id: call_id.to_string(),
264 output: protocol_normalize_output(output),
265 is_error: false,
266 error: None,
267 chunk: None,
268 },
269 HostcallOutcome::StreamChunk {
270 sequence,
271 chunk,
272 is_final,
273 } => HostResultPayload {
274 call_id: call_id.to_string(),
275 output: serde_json::json!({
276 "sequence": sequence,
277 "chunk": chunk,
278 "isFinal": is_final,
279 }),
280 is_error: false,
281 error: None,
282 chunk: Some(HostStreamChunk {
283 index: sequence,
284 is_last: is_final,
285 backpressure: None,
286 }),
287 },
288 HostcallOutcome::Error { code, message } => HostResultPayload {
289 call_id: call_id.to_string(),
290 output: serde_json::json!({}),
291 is_error: true,
292 error: Some(HostCallError {
293 code: protocol_error_code(&code),
294 message,
295 details: None,
296 retryable: None,
297 }),
298 chunk: None,
299 },
300 }
301}
302
303fn hostcall_outcome_to_protocol_result_with_trace(
304 payload: &HostCallPayload,
305 outcome: HostcallOutcome,
306) -> HostResultPayload {
307 match outcome {
308 HostcallOutcome::Success(output) => HostResultPayload {
309 call_id: payload.call_id.clone(),
310 output: protocol_normalize_output(output),
311 is_error: false,
312 error: None,
313 chunk: None,
314 },
315 HostcallOutcome::StreamChunk {
316 sequence,
317 chunk,
318 is_final,
319 } => HostResultPayload {
320 call_id: payload.call_id.clone(),
321 output: serde_json::json!({
322 "sequence": sequence,
323 "chunk": chunk,
324 "isFinal": is_final,
325 }),
326 is_error: false,
327 error: None,
328 chunk: Some(HostStreamChunk {
329 index: sequence,
330 is_last: is_final,
331 backpressure: None,
332 }),
333 },
334 HostcallOutcome::Error { code, message } => {
335 let details = Some(protocol_error_details(payload, &code, &message));
336 HostResultPayload {
337 call_id: payload.call_id.clone(),
338 output: serde_json::json!({}),
339 is_error: true,
340 error: Some(HostCallError {
341 code: protocol_error_code(&code),
342 message,
343 details,
344 retryable: None,
345 }),
346 chunk: None,
347 }
348 }
349 }
350}
351
352const DUAL_EXEC_SAMPLE_MODULUS_PPM: u32 = 1_000_000;
353const DUAL_EXEC_DEFAULT_SAMPLE_PPM: u32 = 25_000;
354const DUAL_EXEC_DEFAULT_DIVERGENCE_WINDOW: usize = 64;
355const DUAL_EXEC_DEFAULT_DIVERGENCE_BUDGET: usize = 3;
356const DUAL_EXEC_DEFAULT_ROLLBACK_REQUESTS: usize = 128;
357const DUAL_EXEC_DEFAULT_OVERHEAD_BUDGET_US: u64 = 1_500;
358const DUAL_EXEC_DEFAULT_OVERHEAD_BACKOFF_REQUESTS: usize = 32;
359
360#[derive(Debug, Clone, Copy)]
361struct DualExecOracleConfig {
362 sample_ppm: u32,
363 divergence_window: usize,
364 divergence_budget: usize,
365 rollback_requests: usize,
366 overhead_budget_us: u64,
367 overhead_backoff_requests: usize,
368}
369
370impl Default for DualExecOracleConfig {
371 fn default() -> Self {
372 Self::from_env()
373 }
374}
375
376impl DualExecOracleConfig {
377 fn from_env() -> Self {
378 let sample_ppm = std::env::var("PI_EXT_DUAL_EXEC_SAMPLE_PPM")
379 .ok()
380 .and_then(|raw| raw.trim().parse::<u32>().ok())
381 .unwrap_or(DUAL_EXEC_DEFAULT_SAMPLE_PPM)
382 .min(DUAL_EXEC_SAMPLE_MODULUS_PPM);
383 let divergence_window = std::env::var("PI_EXT_DUAL_EXEC_DIVERGENCE_WINDOW")
384 .ok()
385 .and_then(|raw| raw.trim().parse::<usize>().ok())
386 .unwrap_or(DUAL_EXEC_DEFAULT_DIVERGENCE_WINDOW)
387 .max(1);
388 let divergence_budget = std::env::var("PI_EXT_DUAL_EXEC_DIVERGENCE_BUDGET")
389 .ok()
390 .and_then(|raw| raw.trim().parse::<usize>().ok())
391 .unwrap_or(DUAL_EXEC_DEFAULT_DIVERGENCE_BUDGET)
392 .max(1);
393 let rollback_requests = std::env::var("PI_EXT_DUAL_EXEC_ROLLBACK_REQUESTS")
394 .ok()
395 .and_then(|raw| raw.trim().parse::<usize>().ok())
396 .unwrap_or(DUAL_EXEC_DEFAULT_ROLLBACK_REQUESTS)
397 .max(1);
398 let overhead_budget_us = std::env::var("PI_EXT_DUAL_EXEC_OVERHEAD_BUDGET_US")
399 .ok()
400 .and_then(|raw| raw.trim().parse::<u64>().ok())
401 .unwrap_or(DUAL_EXEC_DEFAULT_OVERHEAD_BUDGET_US)
402 .max(1);
403 let overhead_backoff_requests = std::env::var("PI_EXT_DUAL_EXEC_OVERHEAD_BACKOFF_REQUESTS")
404 .ok()
405 .and_then(|raw| raw.trim().parse::<usize>().ok())
406 .unwrap_or(DUAL_EXEC_DEFAULT_OVERHEAD_BACKOFF_REQUESTS)
407 .max(1);
408
409 Self {
410 sample_ppm,
411 divergence_window,
412 divergence_budget,
413 rollback_requests,
414 overhead_budget_us,
415 overhead_backoff_requests,
416 }
417 }
418}
419
420#[derive(Debug, Clone, Default)]
421struct DualExecOracleState {
422 sampled_total: u64,
423 matched_total: u64,
424 divergence_total: u64,
425 skipped_unsupported_total: u64,
426 skipped_overhead_total: u64,
427 divergence_window: VecDeque<bool>,
428 rollback_remaining: usize,
429 rollback_reason: Option<String>,
430 overhead_backoff_remaining: usize,
431}
432
433impl DualExecOracleState {
434 fn begin_request(&mut self) {
435 if self.rollback_remaining > 0 {
436 self.rollback_remaining = self.rollback_remaining.saturating_sub(1);
437 if self.rollback_remaining == 0 {
438 self.rollback_reason = None;
439 }
440 }
441 if self.overhead_backoff_remaining > 0 {
442 self.overhead_backoff_remaining = self.overhead_backoff_remaining.saturating_sub(1);
443 }
444 }
445
446 const fn rollback_active(&self) -> bool {
447 self.rollback_remaining > 0
448 }
449
450 const fn record_overhead_budget_exceeded(&mut self, config: DualExecOracleConfig) {
451 self.skipped_overhead_total = self.skipped_overhead_total.saturating_add(1);
452 self.overhead_backoff_remaining = config.overhead_backoff_requests;
453 }
454
455 fn record_sample(
456 &mut self,
457 divergent: bool,
458 config: DualExecOracleConfig,
459 extension_id: Option<&str>,
460 ) -> Option<String> {
461 self.sampled_total = self.sampled_total.saturating_add(1);
462 if divergent {
463 self.divergence_total = self.divergence_total.saturating_add(1);
464 } else {
465 self.matched_total = self.matched_total.saturating_add(1);
466 }
467 self.divergence_window.push_back(divergent);
468 while self.divergence_window.len() > config.divergence_window {
469 let _ = self.divergence_window.pop_front();
470 }
471 let divergence_count = self.divergence_window.iter().filter(|&&flag| flag).count();
472 if divergence_count >= config.divergence_budget {
473 self.rollback_remaining = config.rollback_requests;
474 let reason = format!(
475 "dual_exec_divergence_budget_exceeded:{divergence_count}/{window}:{scope}",
476 window = self.divergence_window.len(),
477 scope = extension_id.unwrap_or("global")
478 );
479 self.rollback_reason = Some(reason.clone());
480 return Some(reason);
481 }
482 None
483 }
484}
485
486#[derive(Debug, Clone, PartialEq, Eq)]
487struct DualExecOutcomeDiff {
488 reason: &'static str,
489 fast_fingerprint: String,
490 compat_fingerprint: String,
491}
492
493fn hostcall_value_fingerprint(value: &Value) -> String {
494 let mut hasher = sha2::Sha256::new();
495 hash_canonical_json(value, &mut hasher);
496 format!("{:x}", hasher.finalize())
497}
498
499fn hostcall_outcome_fingerprint(outcome: &HostcallOutcome) -> String {
500 match outcome {
501 HostcallOutcome::Success(output) => {
502 let hash = hostcall_value_fingerprint(output);
503 format!("success:{hash}")
504 }
505 HostcallOutcome::Error { code, message } => {
506 let hash = hostcall_value_fingerprint(&serde_json::json!({
507 "code": code,
508 "message": message,
509 }));
510 format!("error:{hash}")
511 }
512 HostcallOutcome::StreamChunk {
513 sequence,
514 chunk,
515 is_final,
516 } => {
517 let hash = hostcall_value_fingerprint(&serde_json::json!({
518 "sequence": sequence,
519 "chunk": chunk,
520 "isFinal": is_final,
521 }));
522 format!("stream:{hash}")
523 }
524 }
525}
526
527fn diff_hostcall_outcomes(
528 fast: &HostcallOutcome,
529 compat: &HostcallOutcome,
530) -> Option<DualExecOutcomeDiff> {
531 match (fast, compat) {
532 (HostcallOutcome::Success(a), HostcallOutcome::Success(b)) => {
533 let a_hash = hostcall_value_fingerprint(a);
534 let b_hash = hostcall_value_fingerprint(b);
535 if a_hash == b_hash {
536 None
537 } else {
538 Some(DualExecOutcomeDiff {
539 reason: "success_output_mismatch",
540 fast_fingerprint: format!("success:{a_hash}"),
541 compat_fingerprint: format!("success:{b_hash}"),
542 })
543 }
544 }
545 (
546 HostcallOutcome::Error {
547 code: a_code,
548 message: a_message,
549 },
550 HostcallOutcome::Error {
551 code: b_code,
552 message: b_message,
553 },
554 ) => {
555 if a_code == b_code && a_message == b_message {
556 None
557 } else if a_code != b_code {
558 Some(DualExecOutcomeDiff {
559 reason: "error_code_mismatch",
560 fast_fingerprint: hostcall_outcome_fingerprint(fast),
561 compat_fingerprint: hostcall_outcome_fingerprint(compat),
562 })
563 } else {
564 Some(DualExecOutcomeDiff {
565 reason: "error_message_mismatch",
566 fast_fingerprint: hostcall_outcome_fingerprint(fast),
567 compat_fingerprint: hostcall_outcome_fingerprint(compat),
568 })
569 }
570 }
571 (
572 HostcallOutcome::StreamChunk {
573 sequence: a_seq,
574 chunk: a_chunk,
575 is_final: a_final,
576 },
577 HostcallOutcome::StreamChunk {
578 sequence: b_seq,
579 chunk: b_chunk,
580 is_final: b_final,
581 },
582 ) => {
583 if a_seq == b_seq && a_chunk == b_chunk && a_final == b_final {
584 None
585 } else if a_seq != b_seq {
586 Some(DualExecOutcomeDiff {
587 reason: "stream_sequence_mismatch",
588 fast_fingerprint: hostcall_outcome_fingerprint(fast),
589 compat_fingerprint: hostcall_outcome_fingerprint(compat),
590 })
591 } else if a_final != b_final {
592 Some(DualExecOutcomeDiff {
593 reason: "stream_finality_mismatch",
594 fast_fingerprint: hostcall_outcome_fingerprint(fast),
595 compat_fingerprint: hostcall_outcome_fingerprint(compat),
596 })
597 } else {
598 Some(DualExecOutcomeDiff {
599 reason: "stream_chunk_mismatch",
600 fast_fingerprint: hostcall_outcome_fingerprint(fast),
601 compat_fingerprint: hostcall_outcome_fingerprint(compat),
602 })
603 }
604 }
605 _ => Some(DualExecOutcomeDiff {
606 reason: "outcome_variant_mismatch",
607 fast_fingerprint: hostcall_outcome_fingerprint(fast),
608 compat_fingerprint: hostcall_outcome_fingerprint(compat),
609 }),
610 }
611}
612
613fn should_sample_shadow_dual_exec(request: &HostcallRequest, sample_ppm: u32) -> bool {
614 if sample_ppm == 0 {
615 return false;
616 }
617 if sample_ppm >= DUAL_EXEC_SAMPLE_MODULUS_PPM {
618 return true;
619 }
620 let bucket = shadow_sampling_bucket(request) % DUAL_EXEC_SAMPLE_MODULUS_PPM;
621 bucket < sample_ppm
622}
623
624#[inline]
625fn fnv1a64_update(mut hash: u64, bytes: &[u8]) -> u64 {
626 const FNV1A_PRIME: u64 = 1_099_511_628_211;
627 for &byte in bytes {
628 hash ^= u64::from(byte);
629 hash = hash.wrapping_mul(FNV1A_PRIME);
630 }
631 hash
632}
633
634#[inline]
635fn shadow_sampling_bucket(request: &HostcallRequest) -> u32 {
636 const FNV1A_OFFSET_BASIS: u64 = 14_695_981_039_346_656_037;
638 let mut hash = FNV1A_OFFSET_BASIS;
639 hash = fnv1a64_update(hash, request.call_id.as_bytes());
640 hash = fnv1a64_update(hash, &[0xFF]);
641 hash = fnv1a64_update(hash, &request.trace_id.to_le_bytes());
642 if let Some(extension_id) = request.extension_id.as_deref() {
643 hash = fnv1a64_update(hash, &[0xFE]);
644 hash = fnv1a64_update(hash, extension_id.as_bytes());
645 }
646
647 hash ^= hash >> 33;
649 hash = hash.wrapping_mul(0xff51_afd7_ed55_8ccd);
650 hash ^= hash >> 33;
651 hash = hash.wrapping_mul(0xc4ce_b9fe_1a85_ec53);
652 hash ^= hash >> 33;
653
654 let bytes = hash.to_le_bytes();
655 let low = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
656 let high = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
657 low ^ high
658}
659
660fn normalized_shadow_op(op: &str) -> String {
661 let trimmed = op.trim();
662 let mut normalized = String::with_capacity(trimmed.len());
663 for ch in trimmed.chars() {
664 if ch != '_' {
665 normalized.push(ch.to_ascii_lowercase());
666 }
667 }
668 normalized
669}
670
671#[inline]
672fn with_folded_ascii_alnum_token<T>(token: &str, f: impl FnOnce(&[u8]) -> T) -> T {
673 const INLINE_CAP: usize = 64;
674 let mut inline = [0_u8; INLINE_CAP];
675 let mut inline_len = 0_usize;
676 let mut heap: Option<Vec<u8>> = None;
677
678 for byte in token.trim().bytes() {
679 if !byte.is_ascii_alphanumeric() {
680 continue;
681 }
682 let folded = byte.to_ascii_lowercase();
683 if let Some(buf) = heap.as_mut() {
684 buf.push(folded);
685 continue;
686 }
687 if inline_len < INLINE_CAP {
688 inline[inline_len] = folded;
689 inline_len += 1;
690 } else {
691 let mut buf = Vec::with_capacity(token.len());
692 buf.extend_from_slice(&inline[..inline_len]);
693 buf.push(folded);
694 heap = Some(buf);
695 }
696 }
697
698 if let Some(buf) = heap {
699 f(buf.as_slice())
700 } else {
701 f(&inline[..inline_len])
702 }
703}
704
705fn shadow_safe_session_op(op: &str) -> bool {
706 with_folded_ascii_alnum_token(op, |folded| {
707 matches!(
708 folded,
709 b"getstate"
710 | b"getmessages"
711 | b"getentries"
712 | b"getbranch"
713 | b"getfile"
714 | b"getname"
715 | b"getmodel"
716 | b"getthinkinglevel"
717 | b"getlabel"
718 | b"getlabels"
719 | b"getallsessions"
720 )
721 })
722}
723
724fn shadow_safe_events_op(op: &str) -> bool {
725 with_folded_ascii_alnum_token(op, |folded| {
726 matches!(
727 folded,
728 b"getactivetools"
729 | b"getalltools"
730 | b"getmodel"
731 | b"getthinkinglevel"
732 | b"getflag"
733 | b"listflags"
734 )
735 })
736}
737
738fn shadow_safe_tool(name: &str) -> bool {
739 let name = name.trim();
740 name.eq_ignore_ascii_case("read")
741 || name.eq_ignore_ascii_case("grep")
742 || name.eq_ignore_ascii_case("find")
743 || name.eq_ignore_ascii_case("ls")
744}
745
746fn is_shadow_safe_request(request: &HostcallRequest) -> bool {
747 match &request.kind {
748 HostcallKind::Session { op } => shadow_safe_session_op(op),
749 HostcallKind::Events { op } => shadow_safe_events_op(op),
750 HostcallKind::Tool { name } => shadow_safe_tool(name),
751 HostcallKind::Http
752 | HostcallKind::Exec { .. }
753 | HostcallKind::Ui { .. }
754 | HostcallKind::Log => false,
755 }
756}
757
758fn parse_env_bool(name: &str, default: bool) -> bool {
759 std::env::var(name).ok().map_or(default, |raw| {
760 match raw.trim().to_ascii_lowercase().as_str() {
761 "1" | "true" | "yes" | "on" | "enabled" => true,
762 "0" | "false" | "no" | "off" | "disabled" => false,
763 _ => default,
764 }
765 })
766}
767
768fn io_uring_lane_policy_from_env() -> IoUringLanePolicyConfig {
769 let default = IoUringLanePolicyConfig::conservative();
770 let max_queue_depth = std::env::var("PI_EXT_IO_URING_MAX_QUEUE_DEPTH")
771 .ok()
772 .and_then(|raw| raw.trim().parse::<usize>().ok())
773 .unwrap_or(default.max_queue_depth)
774 .max(1);
775
776 IoUringLanePolicyConfig {
777 enabled: parse_env_bool("PI_EXT_IO_URING_ENABLED", default.enabled),
778 ring_available: parse_env_bool("PI_EXT_IO_URING_RING_AVAILABLE", default.ring_available),
779 max_queue_depth,
780 allow_filesystem: parse_env_bool(
781 "PI_EXT_IO_URING_ALLOW_FILESYSTEM",
782 default.allow_filesystem,
783 ),
784 allow_network: parse_env_bool("PI_EXT_IO_URING_ALLOW_NETWORK", default.allow_network),
785 }
786}
787
788fn io_uring_force_compat_from_env() -> bool {
789 parse_env_bool("PI_EXT_IO_URING_FORCE_COMPAT", false)
790}
791
792fn hostcall_io_hint(kind: &HostcallKind) -> HostcallIoHint {
793 match kind {
794 HostcallKind::Http => HostcallIoHint::IoHeavy,
795 HostcallKind::Tool { name } => {
796 let name = name.trim();
797 if name.eq_ignore_ascii_case("read")
798 || name.eq_ignore_ascii_case("write")
799 || name.eq_ignore_ascii_case("edit")
800 || name.eq_ignore_ascii_case("grep")
801 || name.eq_ignore_ascii_case("find")
802 || name.eq_ignore_ascii_case("ls")
803 {
804 HostcallIoHint::IoHeavy
805 } else if name.eq_ignore_ascii_case("bash") {
806 HostcallIoHint::CpuBound
807 } else {
808 HostcallIoHint::Unknown
809 }
810 }
811 HostcallKind::Session { op } => {
812 let lower = op.trim().to_ascii_lowercase();
813 if lower.contains("save")
814 || lower.contains("append")
815 || lower.contains("write")
816 || lower.contains("export")
817 || lower.contains("import")
818 {
819 HostcallIoHint::IoHeavy
820 } else {
821 HostcallIoHint::Unknown
822 }
823 }
824 HostcallKind::Exec { .. }
825 | HostcallKind::Ui { .. }
826 | HostcallKind::Events { .. }
827 | HostcallKind::Log => HostcallIoHint::CpuBound,
828 }
829}
830
831const fn hostcall_io_hint_label(io_hint: HostcallIoHint) -> &'static str {
832 match io_hint {
833 HostcallIoHint::Unknown => "unknown",
834 HostcallIoHint::IoHeavy => "io_heavy",
835 HostcallIoHint::CpuBound => "cpu_bound",
836 }
837}
838
839const fn hostcall_capability_label(capability: HostcallCapabilityClass) -> &'static str {
840 match capability {
841 HostcallCapabilityClass::Filesystem => "filesystem",
842 HostcallCapabilityClass::Network => "network",
843 HostcallCapabilityClass::Execution => "execution",
844 HostcallCapabilityClass::Session => "session",
845 HostcallCapabilityClass::Events => "events",
846 HostcallCapabilityClass::Environment => "environment",
847 HostcallCapabilityClass::Tool => "tool",
848 HostcallCapabilityClass::Ui => "ui",
849 HostcallCapabilityClass::Telemetry => "telemetry",
850 HostcallCapabilityClass::Unknown => "unknown",
851 }
852}
853
854#[derive(Debug, Clone, Copy, PartialEq, Eq)]
855enum IoUringBridgeState {
856 DelegatedFastPath,
857 CancelledBeforeDispatch,
858 CancelledAfterDispatch,
859}
860
861impl IoUringBridgeState {
862 const fn as_str(self) -> &'static str {
863 match self {
864 Self::DelegatedFastPath => "delegated_fast_path",
865 Self::CancelledBeforeDispatch => "cancelled_before_dispatch",
866 Self::CancelledAfterDispatch => "cancelled_after_dispatch",
867 }
868 }
869}
870
871#[derive(Debug, Clone)]
872struct IoUringBridgeDispatch {
873 outcome: HostcallOutcome,
874 state: IoUringBridgeState,
875 fallback_reason: Option<&'static str>,
876}
877
878fn clone_payload_object_without_key(
879 map: &serde_json::Map<String, Value>,
880 reserved_key: &str,
881) -> serde_json::Map<String, Value> {
882 let mut out = serde_json::Map::with_capacity(map.len());
883 for (key, value) in map {
884 if key == reserved_key {
885 continue;
886 }
887 out.insert(key.clone(), value.clone());
888 }
889 out
890}
891
892fn clone_payload_object_without_two_keys(
893 map: &serde_json::Map<String, Value>,
894 reserved_a: &str,
895 reserved_b: &str,
896) -> serde_json::Map<String, Value> {
897 let mut out = serde_json::Map::with_capacity(map.len());
898 for (key, value) in map {
899 if key == reserved_a || key == reserved_b {
900 continue;
901 }
902 out.insert(key.clone(), value.clone());
903 }
904 out
905}
906
907fn protocol_params_from_request(request: &HostcallRequest) -> Value {
908 match &request.kind {
909 HostcallKind::Tool { name } => {
910 let mut object = serde_json::Map::with_capacity(2);
911 object.insert("name".to_string(), Value::String(name.clone()));
912 object.insert("input".to_string(), request.payload.clone());
913 Value::Object(object)
914 }
915 HostcallKind::Exec { cmd } => {
916 let mut object = match &request.payload {
917 Value::Object(map) => clone_payload_object_without_two_keys(map, "command", "cmd"),
918 Value::Null => serde_json::Map::new(),
919 other => {
920 let mut out = serde_json::Map::new();
921 out.insert("payload".to_string(), other.clone());
922 out
923 }
924 };
925 object.insert("cmd".to_string(), Value::String(cmd.clone()));
926 Value::Object(object)
927 }
928 HostcallKind::Http | HostcallKind::Log => request.payload.clone(),
929 HostcallKind::Session { op } | HostcallKind::Ui { op } | HostcallKind::Events { op } => {
930 let mut object = match &request.payload {
931 Value::Object(map) => clone_payload_object_without_key(map, "op"),
932 Value::Null => serde_json::Map::new(),
933 other => {
934 let mut out = serde_json::Map::new();
935 out.insert("payload".to_string(), other.clone());
936 out
937 }
938 };
939 object.insert("op".to_string(), Value::String(op.clone()));
940 Value::Object(object)
941 }
942 }
943}
944
945fn dual_exec_forensic_bundle(
946 request: &HostcallRequest,
947 diff: &DualExecOutcomeDiff,
948 rollback_reason: Option<&str>,
949 shadow_elapsed_us: f64,
950) -> Value {
951 serde_json::json!({
952 "call_trace": {
953 "call_id": request.call_id,
954 "trace_id": request.trace_id,
955 "extension_id": request.extension_id,
956 "method": request.method(),
957 "params_hash": request.params_hash(),
958 "capability": request.required_capability(),
959 },
960 "lane_decision": {
961 "fast_lane": "fast",
962 "compat_lane": "compat_shadow",
963 },
964 "diff": {
965 "reason": diff.reason,
966 "fast_fingerprint": diff.fast_fingerprint,
967 "compat_fingerprint": diff.compat_fingerprint,
968 "shadow_elapsed_us": shadow_elapsed_us,
969 },
970 "rollback": {
971 "triggered": rollback_reason.is_some(),
972 "reason": rollback_reason,
973 }
974 })
975}
976
977const REGIME_MIN_SAMPLES: usize = 24;
978const REGIME_CUSUM_DRIFT: f64 = 0.03;
979const REGIME_CUSUM_THRESHOLD: f64 = 1.6;
980const REGIME_BOCPD_HAZARD: f64 = 0.08;
981const REGIME_POSTERIOR_DECAY: f64 = 0.92;
982const REGIME_POSTERIOR_THRESHOLD: f64 = 0.45;
983const REGIME_COOLDOWN_OBSERVATIONS: usize = 32;
984const REGIME_CONFIRMATION_STREAK: usize = 2;
985const REGIME_FALLBACK_QUEUE_DEPTH: f64 = 1.0;
986const REGIME_FALLBACK_SERVICE_US: f64 = 1_200.0;
987const REGIME_VARIANCE_FLOOR: f64 = 1e-6;
988const ROLLOUT_ALPHA: f64 = 0.05;
989const ROLLOUT_HIGH_STRATUM_QUEUE_MIN: f64 = 8.0;
990const ROLLOUT_HIGH_STRATUM_SERVICE_US_MIN: f64 = 4_500.0;
991const ROLLOUT_LOW_STRATUM_QUEUE_MAX: f64 = 2.0;
992const ROLLOUT_LOW_STRATUM_SERVICE_US_MAX: f64 = 1_800.0;
993const ROLLOUT_PROMOTE_SCORE_THRESHOLD: f64 = 1.25;
994const ROLLOUT_ROLLBACK_SCORE_THRESHOLD: f64 = 0.70;
995const ROLLOUT_MIN_STRATUM_SAMPLES: usize = 10;
996const ROLLOUT_MIN_TOTAL_SAMPLES: usize = 30;
997const ROLLOUT_LOG_E_CLAMP: f64 = 120.0;
998const ROLLOUT_LR_NULL: f64 = 0.35;
999const ROLLOUT_LR_ALT: f64 = 0.65;
1000const ROLLOUT_FALSE_PROMOTE_LOSS: f64 = 28.0;
1001const ROLLOUT_FALSE_ROLLBACK_LOSS: f64 = 12.0;
1002const ROLLOUT_HOLD_OPPORTUNITY_LOSS: f64 = 10.0;
1003
1004#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1005enum RegimeAdaptationMode {
1006 SequentialFastPath,
1007 InterleavedBatching,
1008}
1009
1010impl RegimeAdaptationMode {
1011 const fn as_str(self) -> &'static str {
1012 match self {
1013 Self::SequentialFastPath => "sequential_fast_path",
1014 Self::InterleavedBatching => "interleaved_batching",
1015 }
1016 }
1017}
1018
1019#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1020enum RegimeTransition {
1021 EnterInterleavedBatching,
1022 ReturnToSequentialFastPath,
1023}
1024
1025impl RegimeTransition {
1026 const fn as_str(self) -> &'static str {
1027 match self {
1028 Self::EnterInterleavedBatching => "enter_interleaved_batching",
1029 Self::ReturnToSequentialFastPath => "return_to_sequential_fast_path",
1030 }
1031 }
1032}
1033
1034#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1035enum RolloutGateAction {
1036 Hold,
1037 PromoteInterleaved,
1038 RollbackSequential,
1039}
1040
1041impl RolloutGateAction {
1042 const fn as_str(self) -> &'static str {
1043 match self {
1044 Self::Hold => "hold",
1045 Self::PromoteInterleaved => "promote_interleaved",
1046 Self::RollbackSequential => "rollback_sequential",
1047 }
1048 }
1049}
1050
1051#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1052enum RolloutEvidenceStratum {
1053 HighContention,
1054 LowContention,
1055 Mixed,
1056}
1057
1058impl RolloutEvidenceStratum {
1059 const fn as_str(self) -> &'static str {
1060 match self {
1061 Self::HighContention => "high_contention",
1062 Self::LowContention => "low_contention",
1063 Self::Mixed => "mixed",
1064 }
1065 }
1066}
1067
1068#[derive(Debug, Clone, Copy)]
1069struct RolloutExpectedLoss {
1070 hold: f64,
1071 promote: f64,
1072 rollback: f64,
1073}
1074
1075#[derive(Debug, Clone, Copy)]
1076struct RolloutGateDecision {
1077 action: RolloutGateAction,
1078 expected_loss: RolloutExpectedLoss,
1079 promote_posterior: f64,
1080 rollback_posterior: f64,
1081 promote_e_process: f64,
1082 rollback_e_process: f64,
1083 evidence_threshold: f64,
1084 total_samples: usize,
1085 high_samples: usize,
1086 low_samples: usize,
1087 coverage_ready: bool,
1088 blocked_underpowered: bool,
1089 blocked_cherry_picked: bool,
1090}
1091
1092#[derive(Debug, Clone)]
1093struct RolloutGateState {
1094 total_samples: usize,
1095 high_samples: usize,
1096 low_samples: usize,
1097 promote_alpha: f64,
1098 promote_beta: f64,
1099 rollback_alpha: f64,
1100 rollback_beta: f64,
1101 promote_log_e: f64,
1102 rollback_log_e: f64,
1103}
1104
1105impl Default for RolloutGateState {
1106 fn default() -> Self {
1107 Self {
1108 total_samples: 0,
1109 high_samples: 0,
1110 low_samples: 0,
1111 promote_alpha: 1.0,
1112 promote_beta: 1.0,
1113 rollback_alpha: 1.0,
1114 rollback_beta: 1.0,
1115 promote_log_e: 0.0,
1116 rollback_log_e: 0.0,
1117 }
1118 }
1119}
1120
1121#[derive(Debug, Clone, Copy)]
1122struct RegimeSignal {
1123 queue_depth: f64,
1124 service_time_us: f64,
1125 opcode_entropy: f64,
1126 llc_miss_rate: f64,
1127}
1128
1129impl RegimeSignal {
1130 fn composite_score(self) -> f64 {
1131 let queue_component = (self.queue_depth / 32.0).min(4.0);
1132 let service_component = (self.service_time_us / 5_000.0).min(4.0);
1133 let entropy_component = (self.opcode_entropy / 4.0).min(2.0);
1134 let llc_component = self.llc_miss_rate.clamp(0.0, 1.0) * 2.0;
1135 0.15f64.mul_add(
1136 llc_component,
1137 0.15f64.mul_add(
1138 entropy_component,
1139 0.35f64.mul_add(queue_component, 0.35 * service_component),
1140 ),
1141 )
1142 }
1143}
1144
1145#[derive(Debug, Clone, Copy)]
1146#[allow(clippy::struct_excessive_bools)]
1147struct RegimeObservation {
1148 score: f64,
1149 mean: f64,
1150 stddev: f64,
1151 upper_cusum: f64,
1152 lower_cusum: f64,
1153 change_posterior: f64,
1154 transition: Option<RegimeTransition>,
1155 mode: RegimeAdaptationMode,
1156 fallback_triggered: bool,
1157 rollout_action: RolloutGateAction,
1158 rollout_stratum: RolloutEvidenceStratum,
1159 rollout_expected_loss: RolloutExpectedLoss,
1160 rollout_promote_posterior: f64,
1161 rollout_rollback_posterior: f64,
1162 rollout_promote_e_process: f64,
1163 rollout_rollback_e_process: f64,
1164 rollout_evidence_threshold: f64,
1165 rollout_total_samples: usize,
1166 rollout_high_samples: usize,
1167 rollout_low_samples: usize,
1168 rollout_coverage_ready: bool,
1169 rollout_blocked_underpowered: bool,
1170 rollout_blocked_cherry_picked: bool,
1171}
1172
1173#[derive(Debug, Clone)]
1174struct RegimeShiftDetector {
1175 sample_count: usize,
1176 mean: f64,
1177 m2: f64,
1178 upper_cusum: f64,
1179 lower_cusum: f64,
1180 change_posterior: f64,
1181 cooldown_remaining: usize,
1182 confirmation_streak: usize,
1183 mode: RegimeAdaptationMode,
1184 rollout_gate: RolloutGateState,
1185}
1186
1187impl Default for RegimeShiftDetector {
1188 fn default() -> Self {
1189 Self {
1190 sample_count: 0,
1191 mean: 0.0,
1192 m2: 0.0,
1193 upper_cusum: 0.0,
1194 lower_cusum: 0.0,
1195 change_posterior: 0.0,
1196 cooldown_remaining: 0,
1197 confirmation_streak: 0,
1198 mode: RegimeAdaptationMode::SequentialFastPath,
1199 rollout_gate: RolloutGateState::default(),
1200 }
1201 }
1202}
1203
1204impl RegimeShiftDetector {
1205 const fn current_mode(&self) -> RegimeAdaptationMode {
1206 self.mode
1207 }
1208
1209 #[allow(clippy::too_many_lines)]
1210 fn observe(&mut self, signal: RegimeSignal) -> RegimeObservation {
1211 let score = signal.composite_score();
1212 let baseline_mean = self.mean;
1213 let baseline_stddev = self.variance().sqrt().max(REGIME_VARIANCE_FLOOR);
1214 let deviation = if self.sample_count > 1 {
1215 score - baseline_mean
1216 } else {
1217 0.0
1218 };
1219
1220 self.upper_cusum = (self.upper_cusum + deviation - REGIME_CUSUM_DRIFT).max(0.0);
1221 self.lower_cusum = (self.lower_cusum + deviation + REGIME_CUSUM_DRIFT).min(0.0);
1222
1223 let z_score = if baseline_stddev > REGIME_VARIANCE_FLOOR {
1224 deviation / baseline_stddev
1225 } else {
1226 0.0
1227 };
1228 let evidence = (z_score.abs() - 0.8).max(0.0);
1229 let change_likelihood = 1.0 - (-evidence).exp();
1230 self.change_posterior = self
1231 .change_posterior
1232 .mul_add(
1233 REGIME_POSTERIOR_DECAY,
1234 REGIME_BOCPD_HAZARD * change_likelihood,
1235 )
1236 .clamp(0.0, 1.0);
1237
1238 let cusum_triggered = self.upper_cusum >= REGIME_CUSUM_THRESHOLD
1239 || self.lower_cusum <= -REGIME_CUSUM_THRESHOLD;
1240 let posterior_triggered = self.change_posterior >= REGIME_POSTERIOR_THRESHOLD;
1241 let candidate_shift =
1242 self.sample_count >= REGIME_MIN_SAMPLES && cusum_triggered && posterior_triggered;
1243 let direction_is_up = self.upper_cusum >= -self.lower_cusum;
1244 let rollout_stratum = rollout_evidence_stratum(signal);
1245 let rollout_decision = self.rollout_gate.observe(
1246 score,
1247 rollout_stratum,
1248 self.mode,
1249 candidate_shift,
1250 direction_is_up,
1251 );
1252
1253 let mut transition = None;
1254 let mut fallback_triggered = false;
1255
1256 if self.cooldown_remaining > 0 {
1257 self.cooldown_remaining = self.cooldown_remaining.saturating_sub(1);
1258 self.confirmation_streak = 0;
1259 } else {
1260 let desired_mode = match rollout_decision.action {
1261 RolloutGateAction::PromoteInterleaved => {
1262 Some(RegimeAdaptationMode::InterleavedBatching)
1263 }
1264 RolloutGateAction::RollbackSequential => {
1265 Some(RegimeAdaptationMode::SequentialFastPath)
1266 }
1267 RolloutGateAction::Hold => None,
1268 };
1269 if let Some(desired_mode) = desired_mode {
1270 if desired_mode == self.mode {
1271 self.confirmation_streak = 0;
1272 } else {
1273 self.confirmation_streak = self.confirmation_streak.saturating_add(1);
1274 if self.confirmation_streak >= REGIME_CONFIRMATION_STREAK {
1275 self.mode = desired_mode;
1276 transition = Some(match desired_mode {
1277 RegimeAdaptationMode::InterleavedBatching => {
1278 RegimeTransition::EnterInterleavedBatching
1279 }
1280 RegimeAdaptationMode::SequentialFastPath => {
1281 RegimeTransition::ReturnToSequentialFastPath
1282 }
1283 });
1284 self.cooldown_remaining = REGIME_COOLDOWN_OBSERVATIONS;
1285 self.upper_cusum = 0.0;
1286 self.lower_cusum = 0.0;
1287 self.change_posterior = self.change_posterior.min(0.5);
1288 self.confirmation_streak = 0;
1289 }
1290 }
1291 } else {
1292 self.confirmation_streak = 0;
1293 }
1294 }
1295
1296 if self.mode == RegimeAdaptationMode::InterleavedBatching
1297 && signal.queue_depth <= REGIME_FALLBACK_QUEUE_DEPTH
1298 && signal.service_time_us <= REGIME_FALLBACK_SERVICE_US
1299 {
1300 self.mode = RegimeAdaptationMode::SequentialFastPath;
1301 transition = Some(RegimeTransition::ReturnToSequentialFastPath);
1302 fallback_triggered = true;
1303 self.cooldown_remaining = REGIME_COOLDOWN_OBSERVATIONS / 2;
1304 self.upper_cusum = 0.0;
1305 self.lower_cusum = 0.0;
1306 self.change_posterior = self.change_posterior.min(0.25);
1307 self.confirmation_streak = 0;
1308 }
1309
1310 self.sample_count = self.sample_count.saturating_add(1);
1311 if self.sample_count == 1 {
1312 self.mean = score;
1313 self.m2 = 0.0;
1314 } else {
1315 let count_f64 = f64::from(u32::try_from(self.sample_count).unwrap_or(u32::MAX));
1316 let delta = score - self.mean;
1317 self.mean += delta / count_f64;
1318 let delta2 = score - self.mean;
1319 self.m2 = delta.mul_add(delta2, self.m2);
1320 }
1321
1322 RegimeObservation {
1323 score,
1324 mean: self.mean,
1325 stddev: self.variance().sqrt().max(REGIME_VARIANCE_FLOOR),
1326 upper_cusum: self.upper_cusum,
1327 lower_cusum: self.lower_cusum,
1328 change_posterior: self.change_posterior,
1329 transition,
1330 mode: self.mode,
1331 fallback_triggered,
1332 rollout_action: rollout_decision.action,
1333 rollout_stratum,
1334 rollout_expected_loss: rollout_decision.expected_loss,
1335 rollout_promote_posterior: rollout_decision.promote_posterior,
1336 rollout_rollback_posterior: rollout_decision.rollback_posterior,
1337 rollout_promote_e_process: rollout_decision.promote_e_process,
1338 rollout_rollback_e_process: rollout_decision.rollback_e_process,
1339 rollout_evidence_threshold: rollout_decision.evidence_threshold,
1340 rollout_total_samples: rollout_decision.total_samples,
1341 rollout_high_samples: rollout_decision.high_samples,
1342 rollout_low_samples: rollout_decision.low_samples,
1343 rollout_coverage_ready: rollout_decision.coverage_ready,
1344 rollout_blocked_underpowered: rollout_decision.blocked_underpowered,
1345 rollout_blocked_cherry_picked: rollout_decision.blocked_cherry_picked,
1346 }
1347 }
1348
1349 fn variance(&self) -> f64 {
1350 if self.sample_count < 2 {
1351 REGIME_VARIANCE_FLOOR
1352 } else {
1353 let denom =
1354 f64::from(u32::try_from(self.sample_count.saturating_sub(1)).unwrap_or(u32::MAX));
1355 (self.m2 / denom).max(REGIME_VARIANCE_FLOOR)
1356 }
1357 }
1358}
1359
1360impl RolloutGateState {
1361 fn observe(
1362 &mut self,
1363 score: f64,
1364 stratum: RolloutEvidenceStratum,
1365 mode: RegimeAdaptationMode,
1366 _candidate_shift: bool,
1367 _direction_is_up: bool,
1368 ) -> RolloutGateDecision {
1369 self.total_samples = self.total_samples.saturating_add(1);
1370 match stratum {
1371 RolloutEvidenceStratum::HighContention => {
1372 self.high_samples = self.high_samples.saturating_add(1);
1373 }
1374 RolloutEvidenceStratum::LowContention => {
1375 self.low_samples = self.low_samples.saturating_add(1);
1376 }
1377 RolloutEvidenceStratum::Mixed => {}
1378 }
1379
1380 match stratum {
1381 RolloutEvidenceStratum::HighContention => {
1382 let promote_signal = score >= ROLLOUT_PROMOTE_SCORE_THRESHOLD;
1383 if promote_signal {
1384 self.promote_alpha += 1.0;
1385 } else {
1386 self.promote_beta += 1.0;
1387 }
1388 self.promote_log_e = (self.promote_log_e
1389 + bernoulli_log_likelihood_ratio(
1390 promote_signal,
1391 ROLLOUT_LR_NULL,
1392 ROLLOUT_LR_ALT,
1393 ))
1394 .clamp(-ROLLOUT_LOG_E_CLAMP, ROLLOUT_LOG_E_CLAMP);
1395 }
1396 RolloutEvidenceStratum::LowContention => {
1397 let rollback_signal = score <= ROLLOUT_ROLLBACK_SCORE_THRESHOLD;
1398 if rollback_signal {
1399 self.rollback_alpha += 1.0;
1400 } else {
1401 self.rollback_beta += 1.0;
1402 }
1403 self.rollback_log_e = (self.rollback_log_e
1404 + bernoulli_log_likelihood_ratio(
1405 rollback_signal,
1406 ROLLOUT_LR_NULL,
1407 ROLLOUT_LR_ALT,
1408 ))
1409 .clamp(-ROLLOUT_LOG_E_CLAMP, ROLLOUT_LOG_E_CLAMP);
1410 }
1411 RolloutEvidenceStratum::Mixed => {}
1412 }
1413
1414 let promote_posterior = self.promote_alpha / (self.promote_alpha + self.promote_beta);
1415 let rollback_posterior = self.rollback_alpha / (self.rollback_alpha + self.rollback_beta);
1416 let promote_e_process = self.promote_log_e.exp();
1417 let rollback_e_process = self.rollback_log_e.exp();
1418 let evidence_threshold = 1.0 / ROLLOUT_ALPHA;
1419 let expected_loss = rollout_expected_loss(mode, promote_posterior, rollback_posterior);
1420
1421 let blocked_underpowered = self.total_samples < ROLLOUT_MIN_TOTAL_SAMPLES;
1422 let blocked_cherry_picked = self.high_samples < ROLLOUT_MIN_STRATUM_SAMPLES
1423 || self.low_samples < ROLLOUT_MIN_STRATUM_SAMPLES;
1424 let coverage_ready = !blocked_underpowered && !blocked_cherry_picked;
1425
1426 let promote_ready = coverage_ready
1427 && mode == RegimeAdaptationMode::SequentialFastPath
1428 && promote_e_process >= evidence_threshold
1429 && expected_loss.promote < expected_loss.hold;
1430
1431 let rollback_ready = coverage_ready
1432 && mode == RegimeAdaptationMode::InterleavedBatching
1433 && rollback_e_process >= evidence_threshold
1434 && expected_loss.rollback < expected_loss.hold;
1435
1436 let action = if promote_ready {
1437 RolloutGateAction::PromoteInterleaved
1438 } else if rollback_ready {
1439 RolloutGateAction::RollbackSequential
1440 } else {
1441 RolloutGateAction::Hold
1442 };
1443
1444 RolloutGateDecision {
1445 action,
1446 expected_loss,
1447 promote_posterior,
1448 rollback_posterior,
1449 promote_e_process,
1450 rollback_e_process,
1451 evidence_threshold,
1452 total_samples: self.total_samples,
1453 high_samples: self.high_samples,
1454 low_samples: self.low_samples,
1455 coverage_ready,
1456 blocked_underpowered,
1457 blocked_cherry_picked,
1458 }
1459 }
1460}
1461
1462fn rollout_evidence_stratum(signal: RegimeSignal) -> RolloutEvidenceStratum {
1463 if signal.queue_depth >= ROLLOUT_HIGH_STRATUM_QUEUE_MIN
1464 || signal.service_time_us >= ROLLOUT_HIGH_STRATUM_SERVICE_US_MIN
1465 {
1466 RolloutEvidenceStratum::HighContention
1467 } else if signal.queue_depth <= ROLLOUT_LOW_STRATUM_QUEUE_MAX
1468 && signal.service_time_us <= ROLLOUT_LOW_STRATUM_SERVICE_US_MAX
1469 {
1470 RolloutEvidenceStratum::LowContention
1471 } else {
1472 RolloutEvidenceStratum::Mixed
1473 }
1474}
1475
1476fn bernoulli_log_likelihood_ratio(observed_true: bool, p0: f64, p1: f64) -> f64 {
1477 let p0 = p0.clamp(1e-6, 1.0 - 1e-6);
1478 let p1 = p1.clamp(1e-6, 1.0 - 1e-6);
1479 if observed_true {
1480 f64::ln(p1 / p0)
1481 } else {
1482 f64::ln((1.0 - p1) / (1.0 - p0))
1483 }
1484}
1485
1486fn rollout_expected_loss(
1487 mode: RegimeAdaptationMode,
1488 promote_posterior: f64,
1489 rollback_posterior: f64,
1490) -> RolloutExpectedLoss {
1491 let hold = ROLLOUT_HOLD_OPPORTUNITY_LOSS
1492 .mul_add(promote_posterior, 3.0f64.mul_add(rollback_posterior, 1.0));
1493 let promote = match mode {
1494 RegimeAdaptationMode::SequentialFastPath => {
1495 ROLLOUT_FALSE_PROMOTE_LOSS.mul_add(1.0 - promote_posterior, 2.0 * rollback_posterior)
1496 }
1497 RegimeAdaptationMode::InterleavedBatching => ROLLOUT_FALSE_PROMOTE_LOSS
1498 .mul_add(1.0 - promote_posterior, ROLLOUT_HOLD_OPPORTUNITY_LOSS),
1499 };
1500 let rollback = match mode {
1501 RegimeAdaptationMode::SequentialFastPath => ROLLOUT_FALSE_ROLLBACK_LOSS
1502 .mul_add(1.0 - rollback_posterior, ROLLOUT_HOLD_OPPORTUNITY_LOSS),
1503 RegimeAdaptationMode::InterleavedBatching => {
1504 ROLLOUT_FALSE_ROLLBACK_LOSS.mul_add(1.0 - rollback_posterior, 2.0 * promote_posterior)
1505 }
1506 };
1507
1508 RolloutExpectedLoss {
1509 hold,
1510 promote,
1511 rollback,
1512 }
1513}
1514
1515fn usize_to_f64(value: usize) -> f64 {
1516 f64::from(u32::try_from(value).unwrap_or(u32::MAX))
1517}
1518
1519fn llc_miss_proxy(total_depth: usize, overflow_depth: usize, overflow_rejected_total: u64) -> f64 {
1520 if total_depth == 0 && overflow_rejected_total == 0 {
1521 return 0.0;
1522 }
1523 let depth_denominator = usize_to_f64(total_depth.max(1));
1524 let overflow_ratio = usize_to_f64(overflow_depth) / depth_denominator;
1525 let rejected_ratio = if overflow_rejected_total == 0 {
1526 0.0
1527 } else {
1528 let rejected = overflow_rejected_total.min(u64::from(u32::MAX));
1529 f64::from(u32::try_from(rejected).unwrap_or(u32::MAX)) / 1_000.0
1530 };
1531 (overflow_ratio + rejected_ratio).clamp(0.0, 1.0)
1532}
1533
1534const fn hostcall_kind_label(kind: &HostcallKind) -> &'static str {
1535 match kind {
1536 HostcallKind::Tool { .. } => "tool",
1537 HostcallKind::Exec { .. } => "exec",
1538 HostcallKind::Http => "http",
1539 HostcallKind::Session { .. } => "session",
1540 HostcallKind::Ui { .. } => "ui",
1541 HostcallKind::Events { .. } => "events",
1542 HostcallKind::Log => "log",
1543 }
1544}
1545
1546fn shannon_entropy_bytes(bytes: &[u8]) -> f64 {
1547 if bytes.is_empty() {
1548 return 0.0;
1549 }
1550 let mut counts = [0_u32; 256];
1551 for &byte in bytes {
1552 counts[usize::from(byte)] = counts[usize::from(byte)].saturating_add(1);
1553 }
1554 let total = f64::from(u32::try_from(bytes.len()).unwrap_or(u32::MAX));
1555 counts
1556 .iter()
1557 .filter(|&&count| count > 0)
1558 .map(|&count| {
1559 let probability = f64::from(count) / total;
1560 -(probability * (probability.ln() / std::f64::consts::LN_2))
1561 })
1562 .sum()
1563}
1564
1565fn hostcall_opcode_entropy(kind: &HostcallKind, payload: &Value) -> f64 {
1566 let kind_label = hostcall_kind_label(kind);
1567 let op = payload
1568 .get("op")
1569 .or_else(|| payload.get("method"))
1570 .or_else(|| payload.get("name"))
1571 .and_then(Value::as_str)
1572 .map(str::trim)
1573 .filter(|value| !value.is_empty());
1574 let capability = payload
1575 .get("capability")
1576 .and_then(Value::as_str)
1577 .map(str::trim)
1578 .filter(|value| !value.is_empty());
1579
1580 let mut counts = [0_u32; 256];
1583 let mut total = 0_u32;
1584
1585 for &byte in kind_label.as_bytes() {
1586 counts[usize::from(byte)] = counts[usize::from(byte)].saturating_add(1);
1587 total = total.saturating_add(1);
1588 }
1589
1590 if let Some(op) = op {
1591 counts[usize::from(b':')] = counts[usize::from(b':')].saturating_add(1);
1592 total = total.saturating_add(1);
1593 for &byte in op.as_bytes() {
1594 counts[usize::from(byte)] = counts[usize::from(byte)].saturating_add(1);
1595 total = total.saturating_add(1);
1596 }
1597 }
1598
1599 if let Some(capability) = capability {
1600 counts[usize::from(b':')] = counts[usize::from(b':')].saturating_add(1);
1601 total = total.saturating_add(1);
1602 for &byte in capability.as_bytes() {
1603 counts[usize::from(byte)] = counts[usize::from(byte)].saturating_add(1);
1604 total = total.saturating_add(1);
1605 }
1606 }
1607
1608 if total == 0 {
1609 return 0.0;
1610 }
1611
1612 let total_f = f64::from(total);
1613 counts
1614 .iter()
1615 .filter(|&&count| count > 0)
1616 .map(|&count| {
1617 let probability = f64::from(count) / total_f;
1618 -(probability * (probability.ln() / std::f64::consts::LN_2))
1619 })
1620 .sum()
1621}
1622
1623impl<C: SchedulerClock + 'static> ExtensionDispatcher<C> {
1624 fn js_runtime(&self) -> &PiJsRuntime<C> {
1625 self.runtime.as_js_runtime()
1626 }
1627
1628 #[allow(clippy::too_many_arguments)]
1629 pub fn new<R>(
1630 runtime: Rc<R>,
1631 tool_registry: Arc<ToolRegistry>,
1632 http_connector: Arc<HttpConnector>,
1633 session: Arc<dyn ExtensionSession + Send + Sync>,
1634 ui_handler: Arc<dyn ExtensionUiHandler + Send + Sync>,
1635 cwd: PathBuf,
1636 ) -> Self
1637 where
1638 R: ExtensionDispatcherRuntime<C>,
1639 {
1640 Self::new_with_policy(
1641 runtime,
1642 tool_registry,
1643 http_connector,
1644 session,
1645 ui_handler,
1646 cwd,
1647 ExtensionPolicy::from_profile(PolicyProfile::Permissive),
1648 )
1649 }
1650
1651 #[allow(clippy::too_many_arguments)]
1652 pub fn new_with_policy<R>(
1653 runtime: Rc<R>,
1654 tool_registry: Arc<ToolRegistry>,
1655 http_connector: Arc<HttpConnector>,
1656 session: Arc<dyn ExtensionSession + Send + Sync>,
1657 ui_handler: Arc<dyn ExtensionUiHandler + Send + Sync>,
1658 cwd: PathBuf,
1659 policy: ExtensionPolicy,
1660 ) -> Self
1661 where
1662 R: ExtensionDispatcherRuntime<C>,
1663 {
1664 Self::new_with_policy_and_oracle_config(
1665 runtime,
1666 tool_registry,
1667 http_connector,
1668 session,
1669 ui_handler,
1670 cwd,
1671 policy,
1672 DualExecOracleConfig::from_env(),
1673 )
1674 }
1675
1676 #[allow(clippy::too_many_arguments)]
1677 fn new_with_policy_and_oracle_config<R>(
1678 runtime: Rc<R>,
1679 tool_registry: Arc<ToolRegistry>,
1680 http_connector: Arc<HttpConnector>,
1681 session: Arc<dyn ExtensionSession + Send + Sync>,
1682 ui_handler: Arc<dyn ExtensionUiHandler + Send + Sync>,
1683 cwd: PathBuf,
1684 policy: ExtensionPolicy,
1685 dual_exec_config: DualExecOracleConfig,
1686 ) -> Self
1687 where
1688 R: ExtensionDispatcherRuntime<C>,
1689 {
1690 let runtime: Rc<dyn ExtensionDispatcherRuntime<C>> = runtime;
1691 let snapshot_version = policy_snapshot_version(&policy);
1692 let snapshot = PolicySnapshot::compile(&policy);
1693 let io_uring_lane_config = io_uring_lane_policy_from_env();
1694 let io_uring_force_compat = io_uring_force_compat_from_env();
1695 Self {
1696 runtime,
1697 tool_registry,
1698 http_connector,
1699 session,
1700 ui_handler,
1701 cwd,
1702 policy,
1703 snapshot,
1704 snapshot_version,
1705 dual_exec_config,
1706 dual_exec_state: RefCell::new(DualExecOracleState::default()),
1707 io_uring_lane_config,
1708 io_uring_force_compat,
1709 regime_detector: RefCell::new(RegimeShiftDetector::default()),
1710 amac_executor: RefCell::new(
1711 AmacBatchExecutor::new(AmacBatchExecutorConfig::from_env()),
1712 ),
1713 }
1714 }
1715
1716 fn policy_lookup(
1717 &self,
1718 capability: &str,
1719 extension_id: Option<&str>,
1720 ) -> (PolicyCheck, &'static str) {
1721 (
1722 self.snapshot.lookup(capability, extension_id),
1723 policy_lookup_path(capability),
1724 )
1725 }
1726
1727 fn emit_policy_decision_telemetry(
1728 &self,
1729 capability: &str,
1730 extension_id: Option<&str>,
1731 lookup_path: &str,
1732 check: &PolicyCheck,
1733 ) {
1734 tracing::debug!(
1735 target: "pi.extensions.policy_snapshot",
1736 snapshot_version = %self.snapshot_version,
1737 lookup_path,
1738 capability = %capability,
1739 extension_id = %extension_id.unwrap_or("<none>"),
1740 decision = ?check.decision,
1741 decision_provenance = %check.reason,
1742 "Extension policy decision evaluated"
1743 );
1744 }
1745
1746 fn emit_regime_observation_telemetry(
1747 call_id: &str,
1748 observation: RegimeObservation,
1749 queue_depth: usize,
1750 overflow_depth: usize,
1751 overflow_rejected_total: u64,
1752 service_time_us: f64,
1753 ) {
1754 tracing::debug!(
1755 target: "pi.extensions.regime_shift",
1756 call_id,
1757 adaptation_mode = observation.mode.as_str(),
1758 composite_score = observation.score,
1759 baseline_mean = observation.mean,
1760 baseline_stddev = observation.stddev,
1761 upper_cusum = observation.upper_cusum,
1762 lower_cusum = observation.lower_cusum,
1763 change_posterior = observation.change_posterior,
1764 queue_depth,
1765 overflow_depth,
1766 overflow_rejected_total,
1767 service_time_us,
1768 fallback_triggered = observation.fallback_triggered,
1769 rollout_action = observation.rollout_action.as_str(),
1770 rollout_stratum = observation.rollout_stratum.as_str(),
1771 rollout_promote_posterior = observation.rollout_promote_posterior,
1772 rollout_rollback_posterior = observation.rollout_rollback_posterior,
1773 rollout_promote_e_process = observation.rollout_promote_e_process,
1774 rollout_rollback_e_process = observation.rollout_rollback_e_process,
1775 rollout_evidence_threshold = observation.rollout_evidence_threshold,
1776 rollout_expected_loss_hold = observation.rollout_expected_loss.hold,
1777 rollout_expected_loss_promote = observation.rollout_expected_loss.promote,
1778 rollout_expected_loss_rollback = observation.rollout_expected_loss.rollback,
1779 rollout_samples_total = observation.rollout_total_samples,
1780 rollout_samples_high = observation.rollout_high_samples,
1781 rollout_samples_low = observation.rollout_low_samples,
1782 rollout_coverage_ready = observation.rollout_coverage_ready,
1783 rollout_blocked_underpowered = observation.rollout_blocked_underpowered,
1784 rollout_blocked_cherry_picked = observation.rollout_blocked_cherry_picked,
1785 "Hostcall regime observation recorded"
1786 );
1787 if let Some(transition) = observation.transition {
1788 tracing::info!(
1789 target: "pi.extensions.regime_shift",
1790 call_id,
1791 transition = transition.as_str(),
1792 adaptation_mode = observation.mode.as_str(),
1793 score = observation.score,
1794 change_posterior = observation.change_posterior,
1795 queue_depth,
1796 service_time_us,
1797 fallback_triggered = observation.fallback_triggered,
1798 rollout_action = observation.rollout_action.as_str(),
1799 rollout_promote_posterior = observation.rollout_promote_posterior,
1800 rollout_rollback_posterior = observation.rollout_rollback_posterior,
1801 rollout_promote_e_process = observation.rollout_promote_e_process,
1802 rollout_rollback_e_process = observation.rollout_rollback_e_process,
1803 rollout_expected_loss_hold = observation.rollout_expected_loss.hold,
1804 rollout_expected_loss_promote = observation.rollout_expected_loss.promote,
1805 rollout_expected_loss_rollback = observation.rollout_expected_loss.rollback,
1806 rollout_samples_total = observation.rollout_total_samples,
1807 rollout_samples_high = observation.rollout_high_samples,
1808 rollout_samples_low = observation.rollout_low_samples,
1809 rollout_coverage_ready = observation.rollout_coverage_ready,
1810 rollout_blocked_underpowered = observation.rollout_blocked_underpowered,
1811 rollout_blocked_cherry_picked = observation.rollout_blocked_cherry_picked,
1812 "Hostcall regime transition accepted"
1813 );
1814 }
1815 }
1816
1817 #[allow(clippy::too_many_arguments)]
1818 fn emit_io_uring_lane_telemetry(
1819 &self,
1820 request: &HostcallRequest,
1821 capability: &str,
1822 capability_class: HostcallCapabilityClass,
1823 io_hint: HostcallIoHint,
1824 queue_depth: usize,
1825 selected_lane: HostcallDispatchLane,
1826 fallback_reason: Option<&'static str>,
1827 ) {
1828 let queue_budget = self.io_uring_lane_config.max_queue_depth.max(1);
1829 let depth_u64 = u64::try_from(queue_depth).unwrap_or(u64::MAX);
1830 let budget_u64 = u64::try_from(queue_budget).unwrap_or(u64::MAX).max(1);
1831 let occupancy_permille = depth_u64.saturating_mul(1_000).saturating_div(budget_u64);
1832 tracing::debug!(
1833 target: "pi.extensions.io_uring_lane",
1834 call_id = request.call_id,
1835 extension_id = %request.extension_id.as_deref().unwrap_or("<none>"),
1836 method = request.method(),
1837 capability = %capability,
1838 capability_class = hostcall_capability_label(capability_class),
1839 io_hint = hostcall_io_hint_label(io_hint),
1840 selected_lane = selected_lane.as_str(),
1841 fallback_reason = %fallback_reason.unwrap_or("none"),
1842 queue_depth,
1843 queue_budget,
1844 queue_occupancy_permille = occupancy_permille,
1845 io_uring_enabled = self.io_uring_lane_config.enabled,
1846 io_uring_ring_available = self.io_uring_lane_config.ring_available,
1847 io_uring_force_compat = self.io_uring_force_compat,
1848 "Hostcall io_uring lane decision evaluated"
1849 );
1850 }
1851
1852 fn emit_io_uring_bridge_telemetry(
1853 &self,
1854 request: &HostcallRequest,
1855 state: IoUringBridgeState,
1856 fallback_reason: Option<&'static str>,
1857 ) {
1858 tracing::debug!(
1859 target: "pi.extensions.io_uring_bridge",
1860 call_id = request.call_id,
1861 extension_id = %request.extension_id.as_deref().unwrap_or("<none>"),
1862 method = request.method(),
1863 state = state.as_str(),
1864 fallback_reason = %fallback_reason.unwrap_or("none"),
1865 io_uring_enabled = self.io_uring_lane_config.enabled,
1866 io_uring_ring_available = self.io_uring_lane_config.ring_available,
1867 io_uring_force_compat = self.io_uring_force_compat,
1868 "Hostcall io_uring bridge dispatch completed"
1869 );
1870 }
1871
1872 const fn advanced_dispatch_enabled(&self) -> bool {
1873 self.dual_exec_config.sample_ppm > 0 || self.io_uring_lane_active()
1874 }
1875
1876 #[inline]
1877 const fn io_uring_lane_active(&self) -> bool {
1878 self.io_uring_lane_config.enabled || self.io_uring_force_compat
1879 }
1880
1881 #[must_use]
1883 pub fn drain_hostcall_requests(&self) -> VecDeque<HostcallRequest> {
1884 self.js_runtime().drain_hostcall_requests()
1885 }
1886
1887 #[allow(clippy::future_not_send)]
1888 async fn dispatch_hostcall_fast(&self, request: &HostcallRequest) -> HostcallOutcome {
1889 let cap = request.required_capability();
1890 let (check, lookup_path) = self.policy_lookup(cap, request.extension_id.as_deref());
1891 self.emit_policy_decision_telemetry(
1892 cap,
1893 request.extension_id.as_deref(),
1894 lookup_path,
1895 &check,
1896 );
1897 if check.decision != PolicyDecision::Allow {
1898 return HostcallOutcome::Error {
1899 code: "denied".to_string(),
1900 message: format!("Capability '{}' denied by policy ({})", cap, check.reason),
1901 };
1902 }
1903
1904 match &request.kind {
1905 HostcallKind::Tool { name } => {
1906 self.dispatch_tool(&request.call_id, name, request.payload.clone())
1907 .await
1908 }
1909 HostcallKind::Exec { cmd } => {
1910 self.dispatch_exec_ref(&request.call_id, cmd, &request.payload)
1911 .await
1912 }
1913 HostcallKind::Http => {
1914 self.dispatch_http(&request.call_id, request.payload.clone())
1915 .await
1916 }
1917 HostcallKind::Session { op } => {
1918 self.dispatch_session_ref(&request.call_id, op, &request.payload)
1919 .await
1920 }
1921 HostcallKind::Ui { op } => {
1922 self.dispatch_ui(
1923 &request.call_id,
1924 op,
1925 request.payload.clone(),
1926 request.extension_id.as_deref(),
1927 )
1928 .await
1929 }
1930 HostcallKind::Events { op } => {
1931 self.dispatch_events_ref(
1932 &request.call_id,
1933 request.extension_id.as_deref(),
1934 op,
1935 &request.payload,
1936 )
1937 .await
1938 }
1939 HostcallKind::Log => {
1940 tracing::info!(
1941 target: "pi.extension.log",
1942 payload = ?request.payload,
1943 "Extension log"
1944 );
1945 HostcallOutcome::Success(serde_json::json!({ "logged": true }))
1946 }
1947 }
1948 }
1949
1950 #[allow(clippy::future_not_send)]
1951 async fn dispatch_hostcall_io_uring(&self, request: &HostcallRequest) -> IoUringBridgeDispatch {
1952 if !self.js_runtime().is_hostcall_active(&request.call_id) {
1953 return IoUringBridgeDispatch {
1954 outcome: HostcallOutcome::Error {
1955 code: "cancelled".to_string(),
1956 message: "Hostcall cancelled before io_uring dispatch".to_string(),
1957 },
1958 state: IoUringBridgeState::CancelledBeforeDispatch,
1959 fallback_reason: Some("cancelled_before_io_uring_dispatch"),
1960 };
1961 }
1962
1963 let delegated_outcome = self.dispatch_hostcall_fast(request).await;
1967 if !self.js_runtime().is_hostcall_active(&request.call_id) {
1968 return IoUringBridgeDispatch {
1969 outcome: HostcallOutcome::Error {
1970 code: "cancelled".to_string(),
1971 message: "Hostcall cancelled before io_uring completion".to_string(),
1972 },
1973 state: IoUringBridgeState::CancelledAfterDispatch,
1974 fallback_reason: Some("cancelled_before_io_uring_completion"),
1975 };
1976 }
1977
1978 IoUringBridgeDispatch {
1979 outcome: delegated_outcome,
1980 state: IoUringBridgeState::DelegatedFastPath,
1981 fallback_reason: Some("io_uring_bridge_delegated_fast_path"),
1982 }
1983 }
1984
1985 #[allow(clippy::future_not_send)]
1986 async fn dispatch_hostcall_compat_shadow(&self, request: &HostcallRequest) -> HostcallOutcome {
1987 let payload = HostCallPayload {
1988 call_id: request.call_id.clone(),
1989 capability: request.required_capability().to_string(),
1990 method: request.method().to_string(),
1991 params: protocol_params_from_request(request),
1992 timeout_ms: None,
1993 cancel_token: None,
1994 context: None,
1995 };
1996 self.dispatch_protocol_host_call(&payload).await
1997 }
1998
1999 #[allow(clippy::future_not_send)]
2000 async fn run_shadow_dual_exec(
2001 &self,
2002 request: &HostcallRequest,
2003 fast_outcome: &HostcallOutcome,
2004 ) {
2005 let config = self.dual_exec_config;
2006 if config.sample_ppm == 0 {
2007 return;
2008 }
2009
2010 {
2011 let mut state = self.dual_exec_state.borrow_mut();
2012 state.begin_request();
2013 if state.overhead_backoff_remaining > 0 {
2014 return;
2015 }
2016 if !is_shadow_safe_request(request) {
2017 state.skipped_unsupported_total = state.skipped_unsupported_total.saturating_add(1);
2018 return;
2019 }
2020 }
2021
2022 if !should_sample_shadow_dual_exec(request, config.sample_ppm) {
2023 return;
2024 }
2025
2026 let shadow_started_at = Instant::now();
2027 let compat_outcome = self.dispatch_hostcall_compat_shadow(request).await;
2028 let shadow_elapsed_us = shadow_started_at.elapsed().as_secs_f64() * 1_000_000.0;
2029
2030 let diff = diff_hostcall_outcomes(fast_outcome, &compat_outcome);
2031 let rollback_reason = {
2032 let mut state = self.dual_exec_state.borrow_mut();
2033 #[allow(clippy::cast_precision_loss)]
2034 if shadow_elapsed_us > config.overhead_budget_us as f64 {
2035 state.record_overhead_budget_exceeded(config);
2036 tracing::warn!(
2037 target: "pi.extensions.dual_exec",
2038 call_id = request.call_id,
2039 extension_id = %request.extension_id.as_deref().unwrap_or("<none>"),
2040 method = request.method(),
2041 shadow_elapsed_us,
2042 overhead_budget_us = config.overhead_budget_us,
2043 backoff_requests = state.overhead_backoff_remaining,
2044 "Shadow dual execution exceeded overhead budget; backoff enabled"
2045 );
2046 }
2047
2048 let divergent = diff.is_some();
2049 state.record_sample(divergent, config, request.extension_id.as_deref())
2050 };
2051
2052 if let Some(diff) = diff {
2053 let forensic_bundle = dual_exec_forensic_bundle(
2054 request,
2055 &diff,
2056 rollback_reason.as_deref(),
2057 shadow_elapsed_us,
2058 );
2059 tracing::warn!(
2060 target: "pi.extensions.dual_exec",
2061 call_id = request.call_id,
2062 extension_id = %request.extension_id.as_deref().unwrap_or("<none>"),
2063 method = request.method(),
2064 rollback_triggered = rollback_reason.is_some(),
2065 rollback_reason = %rollback_reason.as_deref().unwrap_or("none"),
2066 forensic_bundle = %forensic_bundle,
2067 "Shadow dual execution divergence detected"
2068 );
2069 } else {
2070 tracing::trace!(
2071 target: "pi.extensions.dual_exec",
2072 call_id = request.call_id,
2073 extension_id = %request.extension_id.as_deref().unwrap_or("<none>"),
2074 method = request.method(),
2075 shadow_elapsed_us,
2076 "Shadow dual execution matched"
2077 );
2078 }
2079 }
2080
2081 #[allow(clippy::future_not_send, clippy::too_many_lines)]
2083 pub async fn dispatch_and_complete(&self, request: HostcallRequest) {
2084 let cap = request.required_capability();
2085 let (check, lookup_path) = self.policy_lookup(cap, request.extension_id.as_deref());
2086 self.emit_policy_decision_telemetry(
2087 cap,
2088 request.extension_id.as_deref(),
2089 lookup_path,
2090 &check,
2091 );
2092 if check.decision != PolicyDecision::Allow {
2093 let outcome = HostcallOutcome::Error {
2094 code: "denied".to_string(),
2095 message: format!("Capability '{}' denied by policy ({})", cap, check.reason),
2096 };
2097 self.js_runtime()
2098 .complete_hostcall(request.call_id, outcome);
2099 return;
2100 }
2101
2102 if !self.advanced_dispatch_enabled() {
2103 let outcome = self.dispatch_hostcall_fast(&request).await;
2104 self.js_runtime()
2105 .complete_hostcall(request.call_id, outcome);
2106 return;
2107 }
2108
2109 let dispatch_started_at = Instant::now();
2110 let mut queue_depth = 1_usize;
2111 let mut overflow_depth = 0_usize;
2112 let mut overflow_rejected_total = 0_u64;
2113
2114 let (outcome, lane_for_shadow) = if self.io_uring_lane_active() {
2115 let queue_snapshot = self.js_runtime().hostcall_queue_telemetry();
2116 queue_depth = queue_snapshot.total_depth;
2117 overflow_depth = queue_snapshot.overflow_depth;
2118 overflow_rejected_total = queue_snapshot.overflow_rejected_total;
2119
2120 let io_hint = hostcall_io_hint(&request.kind);
2121 let capability_class = HostcallCapabilityClass::from_capability(cap);
2122 let lane_decision = decide_io_uring_lane(
2123 self.io_uring_lane_config,
2124 IoUringLaneDecisionInput {
2125 capability: capability_class,
2126 io_hint,
2127 queue_depth,
2128 force_compat_lane: self.io_uring_force_compat,
2129 },
2130 );
2131 self.emit_io_uring_lane_telemetry(
2132 &request,
2133 cap,
2134 capability_class,
2135 io_hint,
2136 queue_depth,
2137 lane_decision.lane,
2138 lane_decision.fallback_code(),
2139 );
2140
2141 let outcome = match lane_decision.lane {
2142 HostcallDispatchLane::Fast => self.dispatch_hostcall_fast(&request).await,
2143 HostcallDispatchLane::IoUring => {
2144 let bridge_dispatch = self.dispatch_hostcall_io_uring(&request).await;
2145 self.emit_io_uring_bridge_telemetry(
2146 &request,
2147 bridge_dispatch.state,
2148 bridge_dispatch.fallback_reason,
2149 );
2150 bridge_dispatch.outcome
2151 }
2152 HostcallDispatchLane::Compat => {
2153 self.dispatch_hostcall_compat_shadow(&request).await
2154 }
2155 };
2156 (outcome, lane_decision.lane)
2157 } else {
2158 (
2159 self.dispatch_hostcall_fast(&request).await,
2160 HostcallDispatchLane::Fast,
2161 )
2162 };
2163
2164 if lane_for_shadow != HostcallDispatchLane::Compat {
2165 self.run_shadow_dual_exec(&request, &outcome).await;
2166 }
2167
2168 let service_time_us = dispatch_started_at.elapsed().as_secs_f64() * 1_000_000.0;
2169 let opcode_entropy = hostcall_opcode_entropy(&request.kind, &request.payload);
2170 let llc_miss_rate = llc_miss_proxy(queue_depth, overflow_depth, overflow_rejected_total);
2171 let regime_signal = RegimeSignal {
2172 queue_depth: usize_to_f64(queue_depth),
2173 service_time_us,
2174 opcode_entropy,
2175 llc_miss_rate,
2176 };
2177 let observation = {
2178 let mut detector = self.regime_detector.borrow_mut();
2179 detector.observe(regime_signal)
2180 };
2181 Self::emit_regime_observation_telemetry(
2182 &request.call_id,
2183 observation,
2184 queue_depth,
2185 overflow_depth,
2186 overflow_rejected_total,
2187 service_time_us,
2188 );
2189
2190 self.js_runtime()
2191 .complete_hostcall(request.call_id, outcome);
2192 }
2193
2194 #[allow(clippy::future_not_send)]
2201 pub async fn dispatch_batch_amac(&self, mut requests: VecDeque<HostcallRequest>) {
2202 if requests.is_empty() {
2203 return;
2204 }
2205
2206 let (rollback_active, rollback_remaining, rollback_reason) = {
2207 let state = self.dual_exec_state.borrow();
2208 (
2209 state.rollback_active(),
2210 state.rollback_remaining,
2211 state
2212 .rollback_reason
2213 .clone()
2214 .unwrap_or_else(|| "dual_exec_rollback_active".to_string()),
2215 )
2216 };
2217
2218 let amac_enabled = self.amac_executor.borrow().enabled();
2220 let adaptation_mode = self.regime_detector.borrow().current_mode();
2221 let rollout_forces_sequential = adaptation_mode == RegimeAdaptationMode::SequentialFastPath;
2222 if !amac_enabled || rollback_active || rollout_forces_sequential {
2223 if rollback_active {
2224 tracing::warn!(
2225 target: "pi.extensions.dual_exec",
2226 rollback_remaining,
2227 rollback_reason = %rollback_reason,
2228 "Dual-exec rollback forcing sequential dispatcher mode"
2229 );
2230 } else if rollout_forces_sequential && amac_enabled {
2231 tracing::debug!(
2232 target: "pi.extensions.regime_shift",
2233 adaptation_mode = adaptation_mode.as_str(),
2234 "Rollout gate forcing sequential dispatch mode"
2235 );
2236 }
2237 while let Some(req) = requests.pop_front() {
2239 self.dispatch_and_complete(req).await;
2240 }
2241 return;
2242 }
2243
2244 let request_vec: Vec<HostcallRequest> = requests.into();
2245 let plan = self.amac_executor.borrow_mut().plan_batch(request_vec);
2246
2247 for (group, decision) in plan.groups.into_iter().zip(plan.decisions.iter()) {
2248 let group_key = group.key.clone();
2249 let start = Instant::now();
2250 for request in group.requests {
2256 let req_start = Instant::now();
2257 self.dispatch_and_complete(request).await;
2258 let elapsed_ns = u64::try_from(req_start.elapsed().as_nanos()).unwrap_or(u64::MAX);
2259 self.amac_executor.borrow_mut().observe_call(elapsed_ns);
2260 }
2261
2262 let group_elapsed_ns = u64::try_from(start.elapsed().as_nanos()).unwrap_or(u64::MAX);
2263 tracing::trace!(
2264 target: "pi.extensions.amac",
2265 group_key = ?group_key,
2266 decision = ?decision,
2267 group_elapsed_ns,
2268 "AMAC group dispatched"
2269 );
2270 }
2271 }
2272
2273 #[allow(clippy::future_not_send)]
2277 pub async fn dispatch_protocol_message(
2278 &self,
2279 message: ExtensionMessage,
2280 ) -> Result<ExtensionMessage> {
2281 let ExtensionMessage { id, version, body } = message;
2282 if id.trim().is_empty() {
2283 return Err(crate::error::Error::validation(
2284 "Extension message id is empty",
2285 ));
2286 }
2287 if version != PROTOCOL_VERSION {
2288 return Err(crate::error::Error::validation(format!(
2289 "Unsupported extension protocol version: {version}"
2290 )));
2291 }
2292 let ExtensionBody::HostCall(payload) = body else {
2293 return Err(crate::error::Error::validation(
2294 "dispatch_protocol_message expects host_call message",
2295 ));
2296 };
2297
2298 let outcome = match validate_host_call(&payload) {
2299 Ok(()) => self.dispatch_protocol_host_call(&payload).await,
2300 Err(crate::error::Error::Validation(message)) => {
2301 if payload.call_id.trim().is_empty() {
2302 return Err(crate::error::Error::Validation(message));
2303 }
2304 HostcallOutcome::Error {
2305 code: "invalid_request".to_string(),
2306 message,
2307 }
2308 }
2309 Err(err) => return Err(err),
2310 };
2311 let response = ExtensionMessage {
2312 id,
2313 version,
2314 body: ExtensionBody::HostResult(hostcall_outcome_to_protocol_result_with_trace(
2315 &payload, outcome,
2316 )),
2317 };
2318 response.validate()?;
2319 Ok(response)
2320 }
2321
2322 #[allow(clippy::future_not_send, clippy::too_many_lines)]
2323 async fn dispatch_protocol_host_call(&self, payload: &HostCallPayload) -> HostcallOutcome {
2324 if let Some(cap) = required_capability_for_host_call_static(payload) {
2325 let (check, lookup_path) = self.policy_lookup(cap, None);
2326 self.emit_policy_decision_telemetry(cap, None, lookup_path, &check);
2327 if check.decision != PolicyDecision::Allow {
2328 return HostcallOutcome::Error {
2329 code: "denied".to_string(),
2330 message: format!("Capability '{}' denied by policy ({})", cap, check.reason),
2331 };
2332 }
2333 }
2334
2335 let method = payload.method.trim();
2336
2337 match parse_protocol_hostcall_method(method) {
2338 Some(ProtocolHostcallMethod::Tool) => {
2339 let Some(name) = payload
2340 .params
2341 .get("name")
2342 .and_then(Value::as_str)
2343 .map(str::trim)
2344 .filter(|name| !name.is_empty())
2345 else {
2346 return HostcallOutcome::Error {
2347 code: "invalid_request".to_string(),
2348 message: "host_call tool requires params.name".to_string(),
2349 };
2350 };
2351 let input = payload
2352 .params
2353 .get("input")
2354 .cloned()
2355 .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
2356 self.dispatch_tool(&payload.call_id, name, input).await
2357 }
2358 Some(ProtocolHostcallMethod::Exec) => {
2359 let Some(cmd) = payload
2360 .params
2361 .get("cmd")
2362 .or_else(|| payload.params.get("command"))
2363 .and_then(Value::as_str)
2364 .map(str::trim)
2365 .filter(|cmd| !cmd.is_empty())
2366 else {
2367 return HostcallOutcome::Error {
2368 code: "invalid_request".to_string(),
2369 message: "host_call exec requires params.cmd or params.command".to_string(),
2370 };
2371 };
2372
2373 let args: Vec<String> = payload
2375 .params
2376 .get("args")
2377 .and_then(Value::as_array)
2378 .map(|arr| {
2379 arr.iter()
2380 .filter_map(|v| v.as_str().map(ToString::to_string))
2381 .collect()
2382 })
2383 .unwrap_or_default();
2384 let mediation = evaluate_exec_mediation(&self.policy.exec_mediation, cmd, &args);
2385 match &mediation {
2386 ExecMediationResult::Deny { class, reason } => {
2387 tracing::warn!(
2388 event = "exec.mediation.deny",
2389 command_class = ?class.map(DangerousCommandClass::label),
2390 reason = %reason,
2391 "Exec command denied by mediation policy"
2392 );
2393 return HostcallOutcome::Error {
2394 code: "denied".to_string(),
2395 message: format!("Exec denied by mediation policy: {reason}"),
2396 };
2397 }
2398 ExecMediationResult::AllowWithAudit { class, reason } => {
2399 tracing::info!(
2400 event = "exec.mediation.audit",
2401 command_class = class.label(),
2402 reason = %reason,
2403 "Exec command allowed with audit"
2404 );
2405 }
2406 ExecMediationResult::Allow => {}
2407 }
2408
2409 self.dispatch_exec_ref(&payload.call_id, cmd, &payload.params)
2410 .await
2411 }
2412 Some(ProtocolHostcallMethod::Http) => {
2413 self.dispatch_http(&payload.call_id, payload.params.clone())
2414 .await
2415 }
2416 Some(ProtocolHostcallMethod::Session) => {
2417 let Some(op) = protocol_hostcall_op(&payload.params) else {
2418 return HostcallOutcome::Error {
2419 code: "invalid_request".to_string(),
2420 message: "host_call session requires params.op".to_string(),
2421 };
2422 };
2423 self.dispatch_session_ref(&payload.call_id, op, &payload.params)
2424 .await
2425 }
2426 Some(ProtocolHostcallMethod::Ui) => {
2427 let Some(op) = protocol_hostcall_op(&payload.params) else {
2428 return HostcallOutcome::Error {
2429 code: "invalid_request".to_string(),
2430 message: "host_call ui requires params.op".to_string(),
2431 };
2432 };
2433 self.dispatch_ui(&payload.call_id, op, payload.params.clone(), None)
2434 .await
2435 }
2436 Some(ProtocolHostcallMethod::Events) => {
2437 let Some(op) = protocol_hostcall_op(&payload.params) else {
2438 return HostcallOutcome::Error {
2439 code: "invalid_request".to_string(),
2440 message: "host_call events requires params.op".to_string(),
2441 };
2442 };
2443 self.dispatch_events_ref(&payload.call_id, None, op, &payload.params)
2444 .await
2445 }
2446 Some(ProtocolHostcallMethod::Log) => {
2447 tracing::info!(
2448 target: "pi.extension.log",
2449 payload = ?payload.params,
2450 "Extension log"
2451 );
2452 HostcallOutcome::Success(serde_json::json!({ "logged": true }))
2453 }
2454 None => HostcallOutcome::Error {
2455 code: "invalid_request".to_string(),
2456 message: format!("Unsupported host_call method: {method}"),
2457 },
2458 }
2459 }
2460
2461 #[allow(clippy::future_not_send)]
2462 async fn dispatch_tool(
2463 &self,
2464 call_id: &str,
2465 name: &str,
2466 payload: serde_json::Value,
2467 ) -> HostcallOutcome {
2468 let Some(tool) = self.tool_registry.get(name) else {
2469 return HostcallOutcome::Error {
2470 code: "invalid_request".to_string(),
2471 message: format!("Unknown tool: {name}"),
2472 };
2473 };
2474
2475 match tool.execute(call_id, payload, None).await {
2476 Ok(output) => match serde_json::to_value(output) {
2477 Ok(value) => HostcallOutcome::Success(value),
2478 Err(err) => HostcallOutcome::Error {
2479 code: "internal".to_string(),
2480 message: format!("Serialize tool output: {err}"),
2481 },
2482 },
2483 Err(err) => HostcallOutcome::Error {
2484 code: "io".to_string(),
2485 message: err.to_string(),
2486 },
2487 }
2488 }
2489
2490 #[allow(clippy::future_not_send)]
2491 async fn dispatch_exec(
2492 &self,
2493 call_id: &str,
2494 cmd: &str,
2495 payload: serde_json::Value,
2496 ) -> HostcallOutcome {
2497 self.dispatch_exec_ref(call_id, cmd, &payload).await
2498 }
2499
2500 #[allow(clippy::future_not_send, clippy::too_many_lines)]
2501 async fn dispatch_exec_ref(
2502 &self,
2503 call_id: &str,
2504 cmd: &str,
2505 payload: &serde_json::Value,
2506 ) -> HostcallOutcome {
2507 use std::process::{Command, Stdio};
2508 use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
2509 use std::sync::mpsc;
2510
2511 enum ExecStreamFrame {
2512 Stdout(String),
2513 Stderr(String),
2514 Final { code: i32, killed: bool },
2515 Error(String),
2516 }
2517
2518 fn pump_stream<R: std::io::Read>(
2519 mut reader: R,
2520 tx: &std::sync::mpsc::SyncSender<ExecStreamFrame>,
2521 stdout: bool,
2522 ) -> std::result::Result<(), String> {
2523 let mut buf = [0u8; 4096];
2524 let mut partial = Vec::new();
2525
2526 loop {
2527 let read = match reader.read(&mut buf) {
2528 Ok(0) => 0,
2529 Ok(n) => n,
2530 Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
2531 Err(err) => return Err(err.to_string()),
2532 };
2533 if read == 0 {
2534 if !partial.is_empty() {
2536 let text = String::from_utf8_lossy(&partial).to_string();
2537 let frame = if stdout {
2538 ExecStreamFrame::Stdout(text)
2539 } else {
2540 ExecStreamFrame::Stderr(text)
2541 };
2542 let _ = tx.send(frame);
2543 }
2544 break;
2545 }
2546
2547 let chunk = &buf[..read];
2548
2549 if partial.is_empty() {
2552 let mut processed = 0;
2553 loop {
2554 match std::str::from_utf8(&chunk[processed..]) {
2555 Ok(s) => {
2556 if !s.is_empty() {
2557 let frame = if stdout {
2558 ExecStreamFrame::Stdout(s.to_string())
2559 } else {
2560 ExecStreamFrame::Stderr(s.to_string())
2561 };
2562 if tx.send(frame).is_err() {
2563 return Ok(());
2564 }
2565 }
2566 break;
2567 }
2568 Err(e) => {
2569 let valid_len = e.valid_up_to();
2570 if valid_len > 0 {
2571 let s = std::str::from_utf8(
2572 &chunk[processed..processed + valid_len],
2573 )
2574 .expect("valid utf8 prefix");
2575 let frame = if stdout {
2576 ExecStreamFrame::Stdout(s.to_string())
2577 } else {
2578 ExecStreamFrame::Stderr(s.to_string())
2579 };
2580 if tx.send(frame).is_err() {
2581 return Ok(());
2582 }
2583 processed += valid_len;
2584 }
2585
2586 if let Some(len) = e.error_len() {
2587 let frame = if stdout {
2589 ExecStreamFrame::Stdout("\u{FFFD}".to_string())
2590 } else {
2591 ExecStreamFrame::Stderr("\u{FFFD}".to_string())
2592 };
2593 if tx.send(frame).is_err() {
2594 return Ok(());
2595 }
2596 processed += len;
2597 } else {
2598 partial.extend_from_slice(&chunk[processed..]);
2600 break;
2601 }
2602 }
2603 }
2604 }
2605 } else {
2606 partial.extend_from_slice(chunk);
2607 let mut processed = 0;
2608 loop {
2609 match std::str::from_utf8(&partial[processed..]) {
2610 Ok(s) => {
2611 if !s.is_empty() {
2612 let frame = if stdout {
2613 ExecStreamFrame::Stdout(s.to_string())
2614 } else {
2615 ExecStreamFrame::Stderr(s.to_string())
2616 };
2617 if tx.send(frame).is_err() {
2618 return Ok(());
2619 }
2620 }
2621 partial.clear();
2622 break;
2623 }
2624 Err(e) => {
2625 let valid_len = e.valid_up_to();
2626 if valid_len > 0 {
2627 let s = std::str::from_utf8(
2628 &partial[processed..processed + valid_len],
2629 )
2630 .expect("valid utf8 prefix");
2631 let frame = if stdout {
2632 ExecStreamFrame::Stdout(s.to_string())
2633 } else {
2634 ExecStreamFrame::Stderr(s.to_string())
2635 };
2636 if tx.send(frame).is_err() {
2637 return Ok(());
2638 }
2639 processed += valid_len;
2640 }
2641
2642 if let Some(len) = e.error_len() {
2643 let frame = if stdout {
2645 ExecStreamFrame::Stdout("\u{FFFD}".to_string())
2646 } else {
2647 ExecStreamFrame::Stderr("\u{FFFD}".to_string())
2648 };
2649 if tx.send(frame).is_err() {
2650 return Ok(());
2651 }
2652 processed += len;
2653 } else {
2654 let remaining = partial.len() - processed;
2657 partial.copy_within(processed.., 0);
2658 partial.truncate(remaining);
2659 break;
2660 }
2661 }
2662 }
2663 }
2664 }
2665 }
2666 Ok(())
2667 }
2668
2669 #[allow(clippy::unnecessary_lazy_evaluations)] fn exit_status_code(status: std::process::ExitStatus) -> i32 {
2671 status.code().unwrap_or_else(|| {
2672 #[cfg(unix)]
2673 {
2674 use std::os::unix::process::ExitStatusExt as _;
2675 status.signal().map_or(-1, |signal| -signal)
2676 }
2677 #[cfg(not(unix))]
2678 {
2679 -1
2680 }
2681 })
2682 }
2683
2684 let args = match payload.get("args") {
2685 None | Some(serde_json::Value::Null) => Vec::new(),
2686 Some(serde_json::Value::Array(items)) => items
2687 .iter()
2688 .map(|value| {
2689 value
2690 .as_str()
2691 .map_or_else(|| value.to_string(), ToString::to_string)
2692 })
2693 .collect::<Vec<_>>(),
2694 Some(_) => {
2695 return HostcallOutcome::Error {
2696 code: "invalid_request".to_string(),
2697 message: "exec args must be an array".to_string(),
2698 };
2699 }
2700 };
2701
2702 let options = payload
2703 .get("options")
2704 .and_then(serde_json::Value::as_object);
2705 let cwd = options
2706 .and_then(|opts| opts.get("cwd"))
2707 .and_then(serde_json::Value::as_str)
2708 .map_or_else(|| self.cwd.clone(), PathBuf::from);
2709 let timeout_ms = options
2710 .and_then(|opts| {
2711 opts.get("timeout")
2712 .and_then(serde_json::Value::as_u64)
2713 .or_else(|| opts.get("timeoutMs").and_then(serde_json::Value::as_u64))
2714 .or_else(|| opts.get("timeout_ms").and_then(serde_json::Value::as_u64))
2715 })
2716 .filter(|ms| *ms > 0);
2717 let stream = options
2718 .and_then(|opts| opts.get("stream"))
2719 .and_then(serde_json::Value::as_bool)
2720 .unwrap_or(false);
2721
2722 if stream {
2723 struct CancelGuard(Arc<AtomicBool>);
2724 impl Drop for CancelGuard {
2725 fn drop(&mut self) {
2726 self.0.store(true, AtomicOrdering::SeqCst);
2727 }
2728 }
2729
2730 let cmd = cmd.to_string();
2731 let args = args.clone();
2732 let (tx, rx) = mpsc::sync_channel::<ExecStreamFrame>(1024);
2733 let cancel = Arc::new(AtomicBool::new(false));
2734 let cancel_worker = Arc::clone(&cancel);
2735 let call_id_for_error = call_id.to_string();
2736
2737 thread::spawn(move || {
2738 let result = (|| -> std::result::Result<(), String> {
2739 let mut command = Command::new(&cmd);
2740 command
2741 .args(&args)
2742 .stdin(Stdio::null())
2743 .stdout(Stdio::piped())
2744 .stderr(Stdio::piped())
2745 .current_dir(&cwd);
2746 crate::tools::isolate_command_process_group(&mut command);
2747
2748 let mut child = command.spawn().map_err(|err| err.to_string())?;
2749 let pid = child.id();
2750
2751 let stdout = child.stdout.take().ok_or("Missing stdout pipe")?;
2752 let stderr = child.stderr.take().ok_or("Missing stderr pipe")?;
2753
2754 let stdout_tx = tx.clone();
2755 let stderr_tx = tx.clone();
2756 let stdout_handle =
2757 thread::spawn(move || pump_stream(stdout, &stdout_tx, true));
2758 let stderr_handle =
2759 thread::spawn(move || pump_stream(stderr, &stderr_tx, false));
2760
2761 let start = Instant::now();
2762 let mut killed = false;
2763 let status = loop {
2764 if let Some(status) = child.try_wait().map_err(|err| err.to_string())? {
2765 break status;
2766 }
2767
2768 if !killed && cancel_worker.load(AtomicOrdering::SeqCst) {
2769 killed = true;
2770 crate::tools::kill_process_group_tree(Some(pid));
2771 let _ = child.kill();
2772 break child.wait().map_err(|err| err.to_string())?;
2773 }
2774
2775 if let Some(timeout_ms) = timeout_ms {
2776 if !killed && start.elapsed() >= Duration::from_millis(timeout_ms) {
2777 killed = true;
2778 crate::tools::kill_process_group_tree(Some(pid));
2779 let _ = child.kill();
2780 break child.wait().map_err(|err| err.to_string())?;
2781 }
2782 }
2783
2784 thread::sleep(Duration::from_millis(10));
2785 };
2786
2787 let drain_start = Instant::now();
2788 let drain_deadline = drain_start + Duration::from_secs(5);
2789 loop {
2790 if stdout_handle.is_finished() && stderr_handle.is_finished() {
2791 break;
2792 }
2793 if Instant::now() >= drain_deadline {
2794 break;
2795 }
2796 thread::sleep(Duration::from_millis(10));
2797 }
2798
2799 let _ = child.wait();
2802
2803 let code = exit_status_code(status);
2804 let _ = tx.send(ExecStreamFrame::Final { code, killed });
2805 Ok(())
2806 })();
2807
2808 if let Err(err) = result {
2809 if tx.send(ExecStreamFrame::Error(err)).is_err() {
2810 tracing::trace!(
2811 call_id = %call_id_for_error,
2812 "Exec hostcall stream result dropped before completion"
2813 );
2814 }
2815 }
2816 });
2817
2818 let _guard = CancelGuard(Arc::clone(&cancel));
2819
2820 let mut sequence = 0_u64;
2821 let mut processed_in_turn = 0_u32;
2822 loop {
2823 if !self.js_runtime().is_hostcall_active(call_id) {
2824 cancel.store(true, AtomicOrdering::SeqCst);
2825 return HostcallOutcome::StreamChunk {
2826 sequence,
2827 chunk: serde_json::Value::Null,
2828 is_final: false,
2829 };
2830 }
2831
2832 match rx.try_recv() {
2833 Ok(ExecStreamFrame::Stdout(chunk)) => {
2834 self.js_runtime().complete_hostcall(
2835 call_id.to_string(),
2836 HostcallOutcome::StreamChunk {
2837 sequence,
2838 chunk: serde_json::json!({ "stdout": chunk }),
2839 is_final: false,
2840 },
2841 );
2842 sequence = sequence.saturating_add(1);
2843 processed_in_turn += 1;
2844 }
2845 Ok(ExecStreamFrame::Stderr(chunk)) => {
2846 self.js_runtime().complete_hostcall(
2847 call_id.to_string(),
2848 HostcallOutcome::StreamChunk {
2849 sequence,
2850 chunk: serde_json::json!({ "stderr": chunk }),
2851 is_final: false,
2852 },
2853 );
2854 sequence = sequence.saturating_add(1);
2855 processed_in_turn += 1;
2856 }
2857 Ok(ExecStreamFrame::Final { code, killed }) => {
2858 return HostcallOutcome::StreamChunk {
2859 sequence,
2860 chunk: serde_json::json!({
2861 "code": code,
2862 "killed": killed,
2863 }),
2864 is_final: true,
2865 };
2866 }
2867 Ok(ExecStreamFrame::Error(message)) => {
2868 return HostcallOutcome::Error {
2869 code: "io".to_string(),
2870 message,
2871 };
2872 }
2873 Err(mpsc::TryRecvError::Empty) => {
2874 processed_in_turn = 0;
2875 extension_wait_sleep(Duration::from_millis(25)).await;
2876 }
2877 Err(mpsc::TryRecvError::Disconnected) => {
2878 return HostcallOutcome::Error {
2879 code: "internal".to_string(),
2880 message: "exec stream channel closed".to_string(),
2881 };
2882 }
2883 }
2884
2885 if processed_in_turn >= 64 {
2886 processed_in_turn = 0;
2887 asupersync::runtime::yield_now().await;
2888 }
2889 }
2890 }
2891
2892 let cmd = cmd.to_string();
2893 let args = args.clone();
2894 let (tx, rx) = mpsc::sync_channel::<std::result::Result<serde_json::Value, String>>(1);
2895 let cancel = Arc::new(AtomicBool::new(false));
2896 let cancel_worker = Arc::clone(&cancel);
2897 let call_id_for_error = call_id.to_string();
2898
2899 thread::spawn(move || {
2900 #[derive(Clone, Copy)]
2901 enum StreamKind {
2902 Stdout,
2903 Stderr,
2904 }
2905
2906 struct StreamChunk {
2907 kind: StreamKind,
2908 bytes: Vec<u8>,
2909 }
2910
2911 fn pump_stream(
2912 mut reader: impl std::io::Read,
2913 tx: &std::sync::mpsc::SyncSender<StreamChunk>,
2914 kind: StreamKind,
2915 ) {
2916 let mut buf = [0u8; 8192];
2917 loop {
2918 let read = match reader.read(&mut buf) {
2919 Ok(0) => break,
2920 Ok(read) => read,
2921 Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
2922 Err(_) => break,
2923 };
2924 let chunk = StreamChunk {
2925 kind,
2926 bytes: buf[..read].to_vec(),
2927 };
2928 if tx.send(chunk).is_err() {
2929 break;
2930 }
2931 }
2932 }
2933
2934 let result: std::result::Result<serde_json::Value, String> = (|| {
2935 let mut command = Command::new(&cmd);
2936 command
2937 .args(&args)
2938 .stdin(Stdio::null())
2939 .stdout(Stdio::piped())
2940 .stderr(Stdio::piped())
2941 .current_dir(&cwd);
2942 crate::tools::isolate_command_process_group(&mut command);
2943
2944 let mut child = command.spawn().map_err(|err| err.to_string())?;
2945 let pid = child.id();
2946
2947 let stdout = child.stdout.take().ok_or("Missing stdout pipe")?;
2948 let stderr = child.stderr.take().ok_or("Missing stderr pipe")?;
2949
2950 let (tx, rx) = std::sync::mpsc::sync_channel::<StreamChunk>(1024);
2951 let tx_stdout = tx.clone();
2952 let _stdout_handle =
2953 thread::spawn(move || pump_stream(stdout, &tx_stdout, StreamKind::Stdout));
2954 let _stderr_handle =
2955 thread::spawn(move || pump_stream(stderr, &tx, StreamKind::Stderr));
2956
2957 let start = Instant::now();
2958 let mut killed = false;
2959 let max_bytes = crate::tools::DEFAULT_MAX_BYTES.saturating_mul(2);
2960
2961 let mut stdout_chunks = std::collections::VecDeque::new();
2962 let mut stderr_chunks = std::collections::VecDeque::new();
2963 let mut stdout_bytes_len = 0usize;
2964 let mut stderr_bytes_len = 0usize;
2965
2966 let mut ingest_chunk = |kind: StreamKind, bytes: Vec<u8>| match kind {
2967 StreamKind::Stdout => {
2968 stdout_bytes_len += bytes.len();
2969 stdout_chunks.push_back(bytes);
2970 while stdout_bytes_len > max_bytes && stdout_chunks.len() > 1 {
2971 if let Some(front) = stdout_chunks.pop_front() {
2972 stdout_bytes_len -= front.len();
2973 }
2974 }
2975 }
2976 StreamKind::Stderr => {
2977 stderr_bytes_len += bytes.len();
2978 stderr_chunks.push_back(bytes);
2979 while stderr_bytes_len > max_bytes && stderr_chunks.len() > 1 {
2980 if let Some(front) = stderr_chunks.pop_front() {
2981 stderr_bytes_len -= front.len();
2982 }
2983 }
2984 }
2985 };
2986
2987 let status = loop {
2988 while let Ok(chunk) = rx.try_recv() {
2990 ingest_chunk(chunk.kind, chunk.bytes);
2991 }
2992
2993 if let Some(status) = child.try_wait().map_err(|err| err.to_string())? {
2994 break status;
2995 }
2996
2997 if !killed && cancel_worker.load(AtomicOrdering::SeqCst) {
2998 killed = true;
2999 crate::tools::kill_process_group_tree(Some(pid));
3000 let _ = child.kill();
3001 break child.wait().map_err(|err| err.to_string())?;
3002 }
3003
3004 if let Some(timeout_ms) = timeout_ms {
3005 if !killed && start.elapsed() >= Duration::from_millis(timeout_ms) {
3006 killed = true;
3007 crate::tools::kill_process_group_tree(Some(pid));
3008 let _ = child.kill();
3009 break child.wait().map_err(|err| err.to_string())?;
3010 }
3011 }
3012
3013 if let Ok(chunk) = rx.recv_timeout(Duration::from_millis(10)) {
3014 ingest_chunk(chunk.kind, chunk.bytes);
3015 }
3016 };
3017
3018 let drain_deadline = Instant::now() + Duration::from_secs(2);
3019 loop {
3020 match rx.try_recv() {
3021 Ok(chunk) => ingest_chunk(chunk.kind, chunk.bytes),
3022 Err(std::sync::mpsc::TryRecvError::Empty) => {
3023 if Instant::now() >= drain_deadline {
3024 break;
3025 }
3026 thread::sleep(Duration::from_millis(10));
3027 }
3028 Err(std::sync::mpsc::TryRecvError::Disconnected) => break,
3029 }
3030 }
3031
3032 drop(rx); let _ = child.wait();
3038
3039 let stdout_bytes: Vec<u8> = stdout_chunks.into_iter().flatten().collect();
3040 let stderr_bytes: Vec<u8> = stderr_chunks.into_iter().flatten().collect();
3041
3042 let stdout = String::from_utf8_lossy(&stdout_bytes).to_string();
3043 let stderr = String::from_utf8_lossy(&stderr_bytes).to_string();
3044 let code = exit_status_code(status);
3045
3046 Ok(serde_json::json!({
3047 "stdout": stdout,
3048 "stderr": stderr,
3049 "code": code,
3050 "killed": killed,
3051 }))
3052 })();
3053
3054 if tx.send(result).is_err() {
3055 tracing::trace!(
3056 call_id = %call_id_for_error,
3057 "Exec hostcall result dropped before completion"
3058 );
3059 }
3060 });
3061
3062 let _guard = CancelGuard(Arc::clone(&cancel));
3063
3064 loop {
3065 if !self.js_runtime().is_hostcall_active(call_id) {
3066 cancel.store(true, AtomicOrdering::SeqCst);
3067 return HostcallOutcome::Error {
3068 code: "internal".to_string(),
3069 message: "exec task cancelled".to_string(),
3070 };
3071 }
3072
3073 match rx.try_recv() {
3074 Ok(Ok(value)) => return HostcallOutcome::Success(value),
3075 Ok(Err(err)) => {
3076 return HostcallOutcome::Error {
3077 code: "io".to_string(),
3078 message: err,
3079 };
3080 }
3081 Err(mpsc::TryRecvError::Empty) => {
3082 extension_wait_sleep(Duration::from_millis(25)).await;
3083 }
3084 Err(mpsc::TryRecvError::Disconnected) => {
3085 return HostcallOutcome::Error {
3086 code: "internal".to_string(),
3087 message: "exec task cancelled".to_string(),
3088 };
3089 }
3090 }
3091 }
3092 }
3093
3094 #[allow(clippy::future_not_send)]
3095 async fn dispatch_http(&self, call_id: &str, payload: serde_json::Value) -> HostcallOutcome {
3096 let call = HostCallPayload {
3097 call_id: call_id.to_string(),
3098 capability: "http".to_string(),
3099 method: "http".to_string(),
3100 params: payload,
3101 timeout_ms: None,
3102 cancel_token: None,
3103 context: None,
3104 };
3105
3106 match self.http_connector.dispatch(&call).await {
3107 Ok(result) => {
3108 if result.is_error {
3109 let message = result.error.as_ref().map_or_else(
3110 || "HTTP connector error".to_string(),
3111 |err| err.message.clone(),
3112 );
3113 let code = result
3114 .error
3115 .as_ref()
3116 .map_or("internal", |err| hostcall_code_to_str(err.code));
3117 HostcallOutcome::Error {
3118 code: code.to_string(),
3119 message,
3120 }
3121 } else {
3122 HostcallOutcome::Success(result.output)
3123 }
3124 }
3125 Err(err) => HostcallOutcome::Error {
3126 code: "internal".to_string(),
3127 message: err.to_string(),
3128 },
3129 }
3130 }
3131
3132 #[allow(clippy::future_not_send)]
3133 async fn dispatch_session(&self, call_id: &str, op: &str, payload: Value) -> HostcallOutcome {
3134 self.dispatch_session_ref(call_id, op, &payload).await
3135 }
3136
3137 #[allow(clippy::future_not_send, clippy::too_many_lines)]
3138 async fn dispatch_session_ref(
3139 &self,
3140 _call_id: &str,
3141 op: &str,
3142 payload: &Value,
3143 ) -> HostcallOutcome {
3144 use crate::connectors::HostCallErrorCode;
3145
3146 let op_norm = op.trim().to_ascii_lowercase();
3147
3148 let result: std::result::Result<Value, (HostCallErrorCode, String)> = match op_norm.as_str()
3150 {
3151 "get_state" | "getstate" => Ok(self.session.get_state().await),
3152 "get_messages" | "getmessages" => {
3153 serde_json::to_value(self.session.get_messages().await).map_err(|err| {
3154 (
3155 HostCallErrorCode::Internal,
3156 format!("Serialize messages: {err}"),
3157 )
3158 })
3159 }
3160 "get_entries" | "getentries" => serde_json::to_value(self.session.get_entries().await)
3161 .map_err(|err| {
3162 (
3163 HostCallErrorCode::Internal,
3164 format!("Serialize entries: {err}"),
3165 )
3166 }),
3167 "get_branch" | "getbranch" => serde_json::to_value(self.session.get_branch().await)
3168 .map_err(|err| {
3169 (
3170 HostCallErrorCode::Internal,
3171 format!("Serialize branch: {err}"),
3172 )
3173 }),
3174 "get_file" | "getfile" => {
3175 let state = self.session.get_state().await;
3176 let file = state
3177 .get("sessionFile")
3178 .or_else(|| state.get("session_file"))
3179 .cloned()
3180 .unwrap_or(Value::Null);
3181 Ok(file)
3182 }
3183 "get_name" | "getname" => {
3184 let state = self.session.get_state().await;
3185 let name = state
3186 .get("sessionName")
3187 .or_else(|| state.get("session_name"))
3188 .cloned()
3189 .unwrap_or(Value::Null);
3190 Ok(name)
3191 }
3192 "set_name" | "setname" => {
3193 let name = payload
3194 .get("name")
3195 .and_then(Value::as_str)
3196 .unwrap_or_default()
3197 .to_string();
3198 self.session
3199 .set_name(name)
3200 .await
3201 .map(|()| Value::Null)
3202 .map_err(|err| (HostCallErrorCode::Io, err.to_string()))
3203 }
3204 "append_entry" | "appendentry" => {
3205 let custom_type = payload
3206 .get("customType")
3207 .and_then(Value::as_str)
3208 .or_else(|| payload.get("custom_type").and_then(Value::as_str))
3209 .unwrap_or_default()
3210 .to_string();
3211 let data = payload.get("data").cloned();
3212 self.session
3213 .append_custom_entry(custom_type, data)
3214 .await
3215 .map(|()| Value::Null)
3216 .map_err(|err| (HostCallErrorCode::Io, err.to_string()))
3217 }
3218 "append_message" | "appendmessage" => {
3219 let message_value = payload
3220 .get("message")
3221 .cloned()
3222 .unwrap_or_else(|| payload.clone());
3223 match serde_json::from_value(message_value) {
3224 Ok(message) => self
3225 .session
3226 .append_message(message)
3227 .await
3228 .map(|()| Value::Null)
3229 .map_err(|err| (HostCallErrorCode::Io, err.to_string())),
3230 Err(err) => Err((
3231 HostCallErrorCode::InvalidRequest,
3232 format!("Parse message: {err}"),
3233 )),
3234 }
3235 }
3236 "set_model" | "setmodel" => {
3237 let provider = payload
3238 .get("provider")
3239 .and_then(Value::as_str)
3240 .unwrap_or_default()
3241 .to_string();
3242 let model_id = payload
3243 .get("modelId")
3244 .and_then(Value::as_str)
3245 .or_else(|| payload.get("model_id").and_then(Value::as_str))
3246 .unwrap_or_default()
3247 .to_string();
3248 if provider.is_empty() || model_id.is_empty() {
3249 Err((
3250 HostCallErrorCode::InvalidRequest,
3251 "set_model requires 'provider' and 'modelId' fields".to_string(),
3252 ))
3253 } else {
3254 self.session
3255 .set_model(provider, model_id)
3256 .await
3257 .map(|()| Value::Bool(true))
3258 .map_err(|err| (HostCallErrorCode::Io, err.to_string()))
3259 }
3260 }
3261 "get_model" | "getmodel" => {
3262 let (provider, model_id) = self.session.get_model().await;
3263 Ok(serde_json::json!({
3264 "provider": provider,
3265 "modelId": model_id,
3266 }))
3267 }
3268 "set_thinking_level" | "setthinkinglevel" => {
3269 let level = payload
3270 .get("level")
3271 .and_then(Value::as_str)
3272 .or_else(|| payload.get("thinkingLevel").and_then(Value::as_str))
3273 .or_else(|| payload.get("thinking_level").and_then(Value::as_str))
3274 .unwrap_or_default()
3275 .to_string();
3276 if level.is_empty() {
3277 Err((
3278 HostCallErrorCode::InvalidRequest,
3279 "set_thinking_level requires 'level' field".to_string(),
3280 ))
3281 } else {
3282 self.session
3283 .set_thinking_level(level)
3284 .await
3285 .map(|()| Value::Null)
3286 .map_err(|err| (HostCallErrorCode::Io, err.to_string()))
3287 }
3288 }
3289 "get_thinking_level" | "getthinkinglevel" => {
3290 let level = self.session.get_thinking_level().await;
3291 Ok(level.map_or(Value::Null, Value::String))
3292 }
3293 "set_label" | "setlabel" => {
3294 let target_id = payload
3295 .get("targetId")
3296 .and_then(Value::as_str)
3297 .or_else(|| payload.get("target_id").and_then(Value::as_str))
3298 .unwrap_or_default()
3299 .to_string();
3300 let label = payload
3301 .get("label")
3302 .and_then(Value::as_str)
3303 .map(String::from);
3304 if target_id.is_empty() {
3305 Err((
3306 HostCallErrorCode::InvalidRequest,
3307 "set_label requires 'targetId' field".to_string(),
3308 ))
3309 } else {
3310 self.session
3311 .set_label(target_id, label)
3312 .await
3313 .map(|()| Value::Null)
3314 .map_err(|err| (HostCallErrorCode::Io, err.to_string()))
3315 }
3316 }
3317 _ => Err((
3318 HostCallErrorCode::InvalidRequest,
3319 format!("Unknown session op: {op}"),
3320 )),
3321 };
3322
3323 match result {
3324 Ok(value) => HostcallOutcome::Success(value),
3325 Err((code, message)) => HostcallOutcome::Error {
3326 code: hostcall_code_to_str(code).to_string(),
3327 message,
3328 },
3329 }
3330 }
3331
3332 #[allow(clippy::future_not_send)]
3333 async fn dispatch_ui(
3334 &self,
3335 call_id: &str,
3336 op: &str,
3337 payload: Value,
3338 extension_id: Option<&str>,
3339 ) -> HostcallOutcome {
3340 let op = op.trim();
3341 if op.is_empty() {
3342 return HostcallOutcome::Error {
3343 code: "invalid_request".to_string(),
3344 message: "host_call ui requires non-empty op".to_string(),
3345 };
3346 }
3347
3348 let request = ExtensionUiRequest {
3349 id: call_id.to_string(),
3350 method: op.to_string(),
3351 payload,
3352 timeout_ms: None,
3353 extension_id: extension_id.map(ToString::to_string),
3354 };
3355
3356 match self.ui_handler.request_ui(request).await {
3357 Ok(Some(response)) => HostcallOutcome::Success(ui_response_value_for_op(op, &response)),
3358 Ok(None) => HostcallOutcome::Success(Value::Null),
3359 Err(err) => HostcallOutcome::Error {
3360 code: classify_ui_hostcall_error(&err).to_string(),
3361 message: err.to_string(),
3362 },
3363 }
3364 }
3365
3366 #[allow(clippy::future_not_send)]
3367 async fn dispatch_events(
3368 &self,
3369 call_id: &str,
3370 extension_id: Option<&str>,
3371 op: &str,
3372 payload: Value,
3373 ) -> HostcallOutcome {
3374 self.dispatch_events_ref(call_id, extension_id, op, &payload)
3375 .await
3376 }
3377
3378 #[allow(clippy::future_not_send)]
3379 async fn dispatch_events_ref(
3380 &self,
3381 _call_id: &str,
3382 extension_id: Option<&str>,
3383 op: &str,
3384 payload: &Value,
3385 ) -> HostcallOutcome {
3386 match op.trim() {
3387 "list" => match self.list_extension_events(extension_id).await {
3388 Ok(events) => HostcallOutcome::Success(serde_json::json!({ "events": events })),
3389 Err(err) => HostcallOutcome::Error {
3390 code: "io".to_string(),
3391 message: err.to_string(),
3392 },
3393 },
3394 "emit" => {
3395 let event_name = payload
3396 .get("event")
3397 .or_else(|| payload.get("name"))
3398 .and_then(Value::as_str)
3399 .map(str::trim)
3400 .filter(|name| !name.is_empty());
3401
3402 let Some(event_name) = event_name else {
3403 return HostcallOutcome::Error {
3404 code: "invalid_request".to_string(),
3405 message: "events.emit requires non-empty `event`".to_string(),
3406 };
3407 };
3408
3409 let event_payload = payload.get("data").cloned().unwrap_or(Value::Null);
3410 let timeout_ms = payload
3411 .get("timeout_ms")
3412 .and_then(Value::as_u64)
3413 .or_else(|| payload.get("timeoutMs").and_then(Value::as_u64))
3414 .or_else(|| payload.get("timeout").and_then(Value::as_u64))
3415 .filter(|ms| *ms > 0)
3416 .unwrap_or(EXTENSION_EVENT_TIMEOUT_MS);
3417
3418 let ctx_payload = match payload.get("ctx") {
3419 Some(ctx) => ctx.clone(),
3420 None => self.build_default_event_ctx(extension_id).await,
3421 };
3422
3423 match Box::pin(self.dispatch_extension_event(
3424 event_name,
3425 event_payload,
3426 ctx_payload,
3427 timeout_ms,
3428 ))
3429 .await
3430 {
3431 Ok(result) => {
3432 let handler_count = self
3433 .count_event_handlers(event_name)
3434 .await
3435 .unwrap_or_default();
3436
3437 HostcallOutcome::Success(serde_json::json!({
3438 "dispatched": true,
3439 "event": event_name,
3440 "handler_count": handler_count,
3441 "result": result,
3442 }))
3443 }
3444 Err(err) => HostcallOutcome::Error {
3445 code: "io".to_string(),
3446 message: err.to_string(),
3447 },
3448 }
3449 }
3450 other => HostcallOutcome::Error {
3451 code: "invalid_request".to_string(),
3452 message: format!("Unsupported events op: {other}"),
3453 },
3454 }
3455 }
3456
3457 #[allow(clippy::future_not_send)]
3458 async fn list_extension_events(&self, extension_id: Option<&str>) -> Result<Vec<String>> {
3459 #[derive(serde::Deserialize)]
3460 struct Snapshot {
3461 id: String,
3462 #[serde(default)]
3463 event_hooks: Vec<String>,
3464 }
3465
3466 let json = self
3467 .js_runtime()
3468 .with_ctx(|ctx| {
3469 let global = ctx.globals();
3470 let snapshot_fn: rquickjs::Function<'_> = global.get("__pi_snapshot_extensions")?;
3471 let value: rquickjs::Value<'_> = snapshot_fn.call(())?;
3472 js_to_json(&value)
3473 })
3474 .await?;
3475
3476 let snapshots: Vec<Snapshot> = serde_json::from_value(json)
3477 .map_err(|err| crate::error::Error::extension(err.to_string()))?;
3478
3479 let mut events = BTreeSet::new();
3480 match extension_id {
3481 Some(needle) => {
3482 for snapshot in snapshots {
3483 if snapshot.id == needle {
3484 for event in snapshot.event_hooks {
3485 let event = event.trim();
3486 if !event.is_empty() {
3487 events.insert(event.to_string());
3488 }
3489 }
3490 break;
3491 }
3492 }
3493 }
3494 None => {
3495 for snapshot in snapshots {
3496 for event in snapshot.event_hooks {
3497 let event = event.trim();
3498 if !event.is_empty() {
3499 events.insert(event.to_string());
3500 }
3501 }
3502 }
3503 }
3504 }
3505
3506 Ok(events.into_iter().collect())
3507 }
3508
3509 #[allow(clippy::future_not_send)]
3510 async fn count_event_handlers(&self, event_name: &str) -> Result<Option<usize>> {
3511 let literal = serde_json::to_string(event_name)
3512 .map_err(|err| crate::error::Error::extension(err.to_string()))?;
3513
3514 self.js_runtime()
3515 .with_ctx(|ctx| {
3516 let code = format!(
3517 "(function() {{ const handlers = (__pi_hook_index.get({literal}) || []); return handlers.length; }})()"
3518 );
3519 ctx.eval::<usize, _>(code)
3520 .map(Some)
3521 .or(Ok(None))
3522 })
3523 .await
3524 }
3525
3526 #[allow(clippy::future_not_send)]
3527 async fn build_default_event_ctx(&self, _extension_id: Option<&str>) -> Value {
3528 let entries = self.session.get_entries().await;
3529 let branch = self.session.get_branch().await;
3530 let leaf_entry = branch.last().cloned().unwrap_or(Value::Null);
3531
3532 serde_json::json!({
3533 "hasUI": true,
3534 "cwd": self.cwd.display().to_string(),
3535 "sessionEntries": entries,
3536 "branch": branch,
3537 "leafEntry": leaf_entry,
3538 "modelRegistry": {},
3539 })
3540 }
3541
3542 #[allow(clippy::future_not_send)]
3543 async fn dispatch_extension_event(
3544 &self,
3545 event_name: &str,
3546 event_payload: Value,
3547 ctx_payload: Value,
3548 timeout_ms: u64,
3549 ) -> Result<Value> {
3550 #[derive(serde::Deserialize)]
3551 struct JsTaskError {
3552 #[serde(default)]
3553 code: Option<String>,
3554 message: String,
3555 #[serde(default)]
3556 stack: Option<String>,
3557 }
3558
3559 #[derive(serde::Deserialize)]
3560 struct JsTaskState {
3561 status: String,
3562 #[serde(default)]
3563 value: Option<Value>,
3564 #[serde(default)]
3565 error: Option<JsTaskError>,
3566 }
3567
3568 let task_id = format!("task-events-{call_id}", call_id = uuid::Uuid::new_v4());
3569
3570 self.js_runtime()
3571 .with_ctx(|ctx| {
3572 let global = ctx.globals();
3573 let dispatch_fn: rquickjs::Function<'_> =
3574 global.get("__pi_dispatch_extension_event")?;
3575 let task_start: rquickjs::Function<'_> = global.get("__pi_task_start")?;
3576
3577 let event_js = json_to_js(&ctx, &event_payload)?;
3578 let ctx_js = json_to_js(&ctx, &ctx_payload)?;
3579 let promise: rquickjs::Value<'_> =
3580 dispatch_fn.call((event_name.to_string(), event_js, ctx_js))?;
3581 let _task: String = task_start.call((task_id.clone(), promise))?;
3582 Ok(())
3583 })
3584 .await?;
3585
3586 let start = extension_wait_now();
3587 let timeout = Duration::from_millis(timeout_ms.max(1));
3588
3589 loop {
3590 let now = extension_wait_now();
3591 if std::time::Duration::from_nanos(now.duration_since(start)) > timeout {
3592 return Err(crate::error::Error::extension(format!(
3593 "events.emit timed out after {}ms",
3594 timeout.as_millis()
3595 )));
3596 }
3597
3598 let pending = self.js_runtime().drain_hostcall_requests();
3599 self.dispatch_batch_amac(pending).await;
3600
3601 let _ = self.js_runtime().tick().await?;
3602 let _ = self.js_runtime().drain_microtasks().await?;
3603
3604 let state_json = self
3605 .js_runtime()
3606 .with_ctx(|ctx| {
3607 let global = ctx.globals();
3608 let take_fn: rquickjs::Function<'_> = global.get("__pi_task_take")?;
3609 let value: rquickjs::Value<'_> = take_fn.call((task_id.clone(),))?;
3610 js_to_json(&value)
3611 })
3612 .await?;
3613
3614 if state_json.is_null() {
3615 return Err(crate::error::Error::extension(
3616 "events.emit task state missing".to_string(),
3617 ));
3618 }
3619
3620 let state: JsTaskState = serde_json::from_value(state_json)
3621 .map_err(|err| crate::error::Error::extension(err.to_string()))?;
3622
3623 match state.status.as_str() {
3624 "pending" => {
3625 if !self.js_runtime().has_pending() {
3626 extension_wait_sleep(Duration::from_millis(1)).await;
3627 }
3628 }
3629 "resolved" => return Ok(state.value.unwrap_or(Value::Null)),
3630 "rejected" => {
3631 let err = state.error.unwrap_or_else(|| JsTaskError {
3632 code: None,
3633 message: "Unknown JS task error".to_string(),
3634 stack: None,
3635 });
3636 let mut message = err.message;
3637 if let Some(code) = err.code {
3638 message = format!("{code}: {message}");
3639 }
3640 if let Some(stack) = err.stack {
3641 if !stack.is_empty() {
3642 message.push('\n');
3643 message.push_str(&stack);
3644 }
3645 }
3646 return Err(crate::error::Error::extension(message));
3647 }
3648 other => {
3649 return Err(crate::error::Error::extension(format!(
3650 "Unexpected JS task status: {other}"
3651 )));
3652 }
3653 }
3654
3655 extension_wait_sleep(Duration::from_millis(0)).await;
3656 }
3657 }
3658}
3659
3660const fn hostcall_code_to_str(code: crate::connectors::HostCallErrorCode) -> &'static str {
3661 match code {
3662 crate::connectors::HostCallErrorCode::Timeout => "timeout",
3663 crate::connectors::HostCallErrorCode::Denied => "denied",
3664 crate::connectors::HostCallErrorCode::Io => "io",
3665 crate::connectors::HostCallErrorCode::InvalidRequest => "invalid_request",
3666 crate::connectors::HostCallErrorCode::Internal => "internal",
3667 }
3668}
3669
3670#[async_trait]
3672pub trait HostcallHandler: Send + Sync {
3673 async fn handle(&self, params: serde_json::Value) -> HostcallOutcome;
3675
3676 fn capability(&self) -> &'static str;
3678}
3679
3680#[async_trait]
3682pub trait ExtensionUiHandler: Send + Sync {
3683 async fn request_ui(&self, request: ExtensionUiRequest) -> Result<Option<ExtensionUiResponse>>;
3684}
3685
3686#[cfg(test)]
3687#[allow(clippy::arc_with_non_send_sync)]
3688mod tests {
3689 use super::*;
3690
3691 use crate::connectors::http::HttpConnectorConfig;
3692 use crate::error::Error;
3693 use crate::extensions::{
3694 ExtensionBody, ExtensionMessage, ExtensionOverride, ExtensionPolicyMode, HostCallPayload,
3695 PROTOCOL_VERSION, PolicyProfile,
3696 };
3697 use crate::scheduler::DeterministicClock;
3698 use crate::session::SessionMessage;
3699 use serde_json::Value;
3700 use std::collections::HashMap;
3701 use std::io::{Read, Write};
3702 use std::net::TcpListener;
3703 use std::path::Path;
3704 use std::sync::Mutex;
3705
3706 #[test]
3707 fn extension_wait_sleep_uses_current_timer_driver_epoch() {
3708 use asupersync::time::{TimerDriverHandle, VirtualClock};
3709 use asupersync::types::{Budget, RegionId, TaskId, Time};
3710 use std::sync::Arc;
3711
3712 let virtual_clock = Arc::new(VirtualClock::starting_at(Time::from_secs(42)));
3713 let timer_driver = TimerDriverHandle::with_virtual_clock(virtual_clock);
3714 let cx = Cx::new_with_drivers(
3715 RegionId::new_for_test(7, 0),
3716 TaskId::new_for_test(9, 0),
3717 Budget::INFINITE,
3718 None,
3719 None,
3720 None,
3721 Some(timer_driver.clone()),
3722 None,
3723 );
3724 let _current = Cx::set_current(Some(cx));
3725
3726 let now = extension_wait_now();
3727 assert_eq!(now, timer_driver.now());
3728 let sleeper = extension_wait_sleep(Duration::from_millis(5));
3729 assert_eq!(sleeper.remaining(now), Duration::from_millis(5));
3730 }
3731
3732 #[test]
3733 fn ui_confirm_cancel_defaults_to_false() {
3734 let response = ExtensionUiResponse {
3735 id: "req-1".to_string(),
3736 value: None,
3737 cancelled: true,
3738 };
3739 assert_eq!(
3740 ui_response_value_for_op("confirm", &response),
3741 Value::Bool(false)
3742 );
3743 assert_eq!(ui_response_value_for_op("select", &response), Value::Null);
3744 }
3745
3746 #[test]
3747 fn policy_snapshot_version_is_deterministic_for_equivalent_policies() {
3748 let mut policy_a = ExtensionPolicy::default();
3749 let mut override_a = ExtensionOverride::default();
3750 override_a.allow.push("exec".to_string());
3751 policy_a
3752 .per_extension
3753 .insert("ext.alpha".to_string(), override_a.clone());
3754 policy_a
3755 .per_extension
3756 .insert("ext.beta".to_string(), override_a);
3757
3758 let mut policy_b = ExtensionPolicy::default();
3759 let mut override_b = ExtensionOverride::default();
3760 override_b.allow.push("exec".to_string());
3761 policy_b
3763 .per_extension
3764 .insert("ext.beta".to_string(), override_b.clone());
3765 policy_b
3766 .per_extension
3767 .insert("ext.alpha".to_string(), override_b);
3768
3769 assert_eq!(
3770 policy_snapshot_version(&policy_a),
3771 policy_snapshot_version(&policy_b)
3772 );
3773 }
3774
3775 #[test]
3776 fn policy_snapshot_version_changes_on_material_policy_delta() {
3777 let policy_base = ExtensionPolicy::from_profile(PolicyProfile::Standard);
3778 let mut policy_delta = policy_base.clone();
3779 policy_delta.deny_caps.push("http".to_string());
3780
3781 assert_ne!(
3782 policy_snapshot_version(&policy_base),
3783 policy_snapshot_version(&policy_delta)
3784 );
3785 }
3786
3787 #[test]
3788 fn policy_lookup_path_marks_known_vs_fallback_capabilities() {
3789 assert_eq!(policy_lookup_path("read"), "policy_snapshot_table");
3790 assert_eq!(policy_lookup_path("READ"), "policy_snapshot_table");
3791 assert_eq!(
3792 policy_lookup_path("non_standard_custom_capability"),
3793 "policy_snapshot_fallback"
3794 );
3795 }
3796
3797 #[test]
3798 fn policy_snapshot_lookup_swaps_decision_across_profile_change() {
3799 let safe_policy = ExtensionPolicy::from_profile(PolicyProfile::Safe);
3800 let permissive_policy = ExtensionPolicy::from_profile(PolicyProfile::Permissive);
3801
3802 let safe_snapshot = PolicySnapshot::compile(&safe_policy);
3803 let permissive_snapshot = PolicySnapshot::compile(&permissive_policy);
3804
3805 let safe_first = safe_snapshot.lookup("exec", Some("ext.swap"));
3806 let safe_second = safe_snapshot.lookup("EXEC", Some("ext.swap"));
3807 assert_eq!(safe_first.decision, PolicyDecision::Deny);
3808 assert_eq!(safe_first.decision, safe_second.decision);
3809
3810 let permissive_first = permissive_snapshot.lookup("exec", Some("ext.swap"));
3811 let permissive_second = permissive_snapshot.lookup("EXEC", Some("ext.swap"));
3812 assert_eq!(permissive_first.decision, PolicyDecision::Allow);
3813 assert_eq!(permissive_first.decision, permissive_second.decision);
3814 }
3815
3816 struct NullSession;
3817
3818 #[async_trait]
3819 impl ExtensionSession for NullSession {
3820 async fn get_state(&self) -> Value {
3821 Value::Null
3822 }
3823
3824 async fn get_messages(&self) -> Vec<SessionMessage> {
3825 Vec::new()
3826 }
3827
3828 async fn get_entries(&self) -> Vec<Value> {
3829 Vec::new()
3830 }
3831
3832 async fn get_branch(&self) -> Vec<Value> {
3833 Vec::new()
3834 }
3835
3836 async fn set_name(&self, _name: String) -> Result<()> {
3837 Ok(())
3838 }
3839
3840 async fn append_message(&self, _message: SessionMessage) -> Result<()> {
3841 Ok(())
3842 }
3843
3844 async fn append_custom_entry(
3845 &self,
3846 _custom_type: String,
3847 _data: Option<Value>,
3848 ) -> Result<()> {
3849 Ok(())
3850 }
3851
3852 async fn set_model(&self, _provider: String, _model_id: String) -> Result<()> {
3853 Ok(())
3854 }
3855
3856 async fn get_model(&self) -> (Option<String>, Option<String>) {
3857 (None, None)
3858 }
3859
3860 async fn set_thinking_level(&self, _level: String) -> Result<()> {
3861 Ok(())
3862 }
3863
3864 async fn get_thinking_level(&self) -> Option<String> {
3865 None
3866 }
3867
3868 async fn set_label(&self, _target_id: String, _label: Option<String>) -> Result<()> {
3869 Ok(())
3870 }
3871 }
3872
3873 struct NullUiHandler;
3874
3875 #[async_trait]
3876 impl ExtensionUiHandler for NullUiHandler {
3877 async fn request_ui(
3878 &self,
3879 _request: ExtensionUiRequest,
3880 ) -> Result<Option<ExtensionUiResponse>> {
3881 Ok(None)
3882 }
3883 }
3884
3885 struct TestUiHandler {
3886 captured: Arc<Mutex<Vec<ExtensionUiRequest>>>,
3887 response_value: Value,
3888 }
3889
3890 #[async_trait]
3891 impl ExtensionUiHandler for TestUiHandler {
3892 async fn request_ui(
3893 &self,
3894 request: ExtensionUiRequest,
3895 ) -> Result<Option<ExtensionUiResponse>> {
3896 self.captured
3897 .lock()
3898 .unwrap_or_else(std::sync::PoisonError::into_inner)
3899 .push(request.clone());
3900 Ok(Some(ExtensionUiResponse {
3901 id: request.id,
3902 value: Some(self.response_value.clone()),
3903 cancelled: false,
3904 }))
3905 }
3906 }
3907
3908 type CustomEntry = (String, Option<Value>);
3909 type CustomEntries = Arc<Mutex<Vec<CustomEntry>>>;
3910
3911 type LabelEntry = (String, Option<String>);
3912
3913 struct TestSession {
3914 state: Arc<Mutex<Value>>,
3915 messages: Arc<Mutex<Vec<SessionMessage>>>,
3916 entries: Arc<Mutex<Vec<Value>>>,
3917 branch: Arc<Mutex<Vec<Value>>>,
3918 name: Arc<Mutex<Option<String>>>,
3919 custom_entries: CustomEntries,
3920 labels: Arc<Mutex<Vec<LabelEntry>>>,
3921 }
3922
3923 #[async_trait]
3924 impl ExtensionSession for TestSession {
3925 async fn get_state(&self) -> Value {
3926 self.state
3927 .lock()
3928 .unwrap_or_else(std::sync::PoisonError::into_inner)
3929 .clone()
3930 }
3931
3932 async fn get_messages(&self) -> Vec<SessionMessage> {
3933 self.messages
3934 .lock()
3935 .unwrap_or_else(std::sync::PoisonError::into_inner)
3936 .clone()
3937 }
3938
3939 async fn get_entries(&self) -> Vec<Value> {
3940 self.entries
3941 .lock()
3942 .unwrap_or_else(std::sync::PoisonError::into_inner)
3943 .clone()
3944 }
3945
3946 async fn get_branch(&self) -> Vec<Value> {
3947 self.branch
3948 .lock()
3949 .unwrap_or_else(std::sync::PoisonError::into_inner)
3950 .clone()
3951 }
3952
3953 async fn set_name(&self, name: String) -> Result<()> {
3954 {
3955 let mut guard = self
3956 .name
3957 .lock()
3958 .unwrap_or_else(std::sync::PoisonError::into_inner);
3959 *guard = Some(name.clone());
3960 }
3961 let mut state = self
3962 .state
3963 .lock()
3964 .unwrap_or_else(std::sync::PoisonError::into_inner);
3965 if let Value::Object(ref mut map) = *state {
3966 map.insert("sessionName".to_string(), Value::String(name));
3967 }
3968 drop(state);
3969 Ok(())
3970 }
3971
3972 async fn append_message(&self, message: SessionMessage) -> Result<()> {
3973 self.messages
3974 .lock()
3975 .unwrap_or_else(std::sync::PoisonError::into_inner)
3976 .push(message);
3977 Ok(())
3978 }
3979
3980 async fn append_custom_entry(
3981 &self,
3982 custom_type: String,
3983 data: Option<Value>,
3984 ) -> Result<()> {
3985 self.custom_entries
3986 .lock()
3987 .unwrap_or_else(std::sync::PoisonError::into_inner)
3988 .push((custom_type, data));
3989 Ok(())
3990 }
3991
3992 async fn set_model(&self, provider: String, model_id: String) -> Result<()> {
3993 let mut state = self
3994 .state
3995 .lock()
3996 .unwrap_or_else(std::sync::PoisonError::into_inner);
3997 if let Value::Object(ref mut map) = *state {
3998 map.insert("provider".to_string(), Value::String(provider));
3999 map.insert("modelId".to_string(), Value::String(model_id));
4000 }
4001 drop(state);
4002 Ok(())
4003 }
4004
4005 async fn get_model(&self) -> (Option<String>, Option<String>) {
4006 let state = self
4007 .state
4008 .lock()
4009 .unwrap_or_else(std::sync::PoisonError::into_inner);
4010 let provider = state
4011 .get("provider")
4012 .and_then(Value::as_str)
4013 .map(String::from);
4014 let model_id = state
4015 .get("modelId")
4016 .and_then(Value::as_str)
4017 .map(String::from);
4018 drop(state);
4019 (provider, model_id)
4020 }
4021
4022 async fn set_thinking_level(&self, level: String) -> Result<()> {
4023 let mut state = self
4024 .state
4025 .lock()
4026 .unwrap_or_else(std::sync::PoisonError::into_inner);
4027 if let Value::Object(ref mut map) = *state {
4028 map.insert("thinkingLevel".to_string(), Value::String(level));
4029 }
4030 drop(state);
4031 Ok(())
4032 }
4033
4034 async fn get_thinking_level(&self) -> Option<String> {
4035 let state = self
4036 .state
4037 .lock()
4038 .unwrap_or_else(std::sync::PoisonError::into_inner);
4039 let level = state
4040 .get("thinkingLevel")
4041 .and_then(Value::as_str)
4042 .map(String::from);
4043 drop(state);
4044 level
4045 }
4046
4047 async fn set_label(&self, target_id: String, label: Option<String>) -> Result<()> {
4048 self.labels
4049 .lock()
4050 .unwrap_or_else(std::sync::PoisonError::into_inner)
4051 .push((target_id, label));
4052 Ok(())
4053 }
4054 }
4055
4056 fn build_dispatcher(
4057 runtime: Rc<PiJsRuntime<DeterministicClock>>,
4058 ) -> ExtensionDispatcher<DeterministicClock> {
4059 build_dispatcher_with_policy(
4060 runtime,
4061 ExtensionPolicy::from_profile(PolicyProfile::Permissive),
4062 )
4063 }
4064
4065 fn build_dispatcher_with_policy(
4066 runtime: Rc<PiJsRuntime<DeterministicClock>>,
4067 policy: ExtensionPolicy,
4068 ) -> ExtensionDispatcher<DeterministicClock> {
4069 ExtensionDispatcher::new_with_policy(
4070 runtime,
4071 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
4072 Arc::new(HttpConnector::with_defaults()),
4073 Arc::new(NullSession),
4074 Arc::new(NullUiHandler),
4075 PathBuf::from("."),
4076 policy,
4077 )
4078 }
4079
4080 fn build_dispatcher_with_policy_and_oracle(
4081 runtime: Rc<PiJsRuntime<DeterministicClock>>,
4082 policy: ExtensionPolicy,
4083 oracle_config: DualExecOracleConfig,
4084 ) -> ExtensionDispatcher<DeterministicClock> {
4085 ExtensionDispatcher::new_with_policy_and_oracle_config(
4086 runtime,
4087 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
4088 Arc::new(HttpConnector::with_defaults()),
4089 Arc::new(NullSession),
4090 Arc::new(NullUiHandler),
4091 PathBuf::from("."),
4092 policy,
4093 oracle_config,
4094 )
4095 }
4096
4097 fn spawn_http_server(body: &'static str) -> std::net::SocketAddr {
4098 let listener = TcpListener::bind("127.0.0.1:0").expect("bind http server");
4099 let addr = listener.local_addr().expect("server addr");
4100 thread::spawn(move || {
4101 if let Ok((mut stream, _)) = listener.accept() {
4102 let mut buf = [0u8; 1024];
4103 let _ = stream.read(&mut buf);
4104 let response = format!(
4105 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: text/plain\r\n\r\n{}",
4106 body.len(),
4107 body
4108 );
4109 let _ = stream.write_all(response.as_bytes());
4110 }
4111 });
4112 addr
4113 }
4114
4115 #[test]
4116 fn dispatcher_constructs() {
4117 futures::executor::block_on(async {
4118 let runtime = Rc::new(
4119 PiJsRuntime::with_clock(DeterministicClock::new(0))
4120 .await
4121 .expect("runtime"),
4122 );
4123 let dispatcher = build_dispatcher(Rc::clone(&runtime));
4124 assert!(std::ptr::eq(
4125 dispatcher.runtime.as_js_runtime(),
4126 runtime.as_ref()
4127 ));
4128 assert_eq!(dispatcher.cwd, PathBuf::from("."));
4129 });
4130 }
4131
4132 #[test]
4133 fn dispatcher_drains_empty_queue() {
4134 futures::executor::block_on(async {
4135 let runtime = Rc::new(
4136 PiJsRuntime::with_clock(DeterministicClock::new(0))
4137 .await
4138 .expect("runtime"),
4139 );
4140 let dispatcher = build_dispatcher(Rc::clone(&runtime));
4141 let drained = dispatcher.drain_hostcall_requests();
4142 assert!(drained.is_empty());
4143 });
4144 }
4145
4146 #[test]
4147 fn dispatcher_drains_runtime_requests() {
4148 futures::executor::block_on(async {
4149 let runtime = Rc::new(
4150 PiJsRuntime::with_clock(DeterministicClock::new(0))
4151 .await
4152 .expect("runtime"),
4153 );
4154 runtime
4155 .eval(r#"pi.tool("read", { "path": "test.txt" });"#)
4156 .await
4157 .expect("eval");
4158
4159 let dispatcher = build_dispatcher(Rc::clone(&runtime));
4160 let drained = dispatcher.drain_hostcall_requests();
4161 assert_eq!(drained.len(), 1);
4162 });
4163 }
4164
4165 #[test]
4166 fn dispatcher_tool_hostcall_executes_and_resolves_promise() {
4167 futures::executor::block_on(async {
4168 let temp_dir = tempfile::tempdir().expect("tempdir");
4169 std::fs::write(temp_dir.path().join("test.txt"), "hello world").expect("write file");
4170
4171 let runtime = Rc::new(
4172 PiJsRuntime::with_clock(DeterministicClock::new(0))
4173 .await
4174 .expect("runtime"),
4175 );
4176 runtime
4177 .eval(
4178 r#"
4179 globalThis.result = null;
4180 pi.tool("read", { path: "test.txt" }).then((r) => { globalThis.result = r; });
4181 "#,
4182 )
4183 .await
4184 .expect("eval");
4185
4186 let requests = runtime.drain_hostcall_requests();
4187 assert_eq!(requests.len(), 1);
4188
4189 let dispatcher = ExtensionDispatcher::new(
4190 Rc::clone(&runtime),
4191 Arc::new(ToolRegistry::new(&["read"], temp_dir.path(), None)),
4192 Arc::new(HttpConnector::with_defaults()),
4193 Arc::new(NullSession),
4194 Arc::new(NullUiHandler),
4195 temp_dir.path().to_path_buf(),
4196 );
4197
4198 for request in requests {
4199 dispatcher.dispatch_and_complete(request).await;
4200 }
4201
4202 let stats = runtime.tick().await.expect("tick");
4203 assert!(stats.ran_macrotask);
4204
4205 runtime
4206 .eval(
4207 r#"
4208 if (globalThis.result === null) throw new Error("Promise not resolved");
4209 if (!JSON.stringify(globalThis.result).includes("hello world")) {
4210 throw new Error("Wrong result: " + JSON.stringify(globalThis.result));
4211 }
4212 "#,
4213 )
4214 .await
4215 .expect("verify result");
4216 });
4217 }
4218
4219 #[test]
4220 fn dispatcher_tool_hostcall_unknown_tool_rejects_promise() {
4221 futures::executor::block_on(async {
4222 let runtime = Rc::new(
4223 PiJsRuntime::with_clock(DeterministicClock::new(0))
4224 .await
4225 .expect("runtime"),
4226 );
4227 runtime
4228 .eval(
4229 r#"
4230 globalThis.err = null;
4231 pi.tool("nope", {}).catch((e) => { globalThis.err = e.code; });
4232 "#,
4233 )
4234 .await
4235 .expect("eval");
4236
4237 let requests = runtime.drain_hostcall_requests();
4238 assert_eq!(requests.len(), 1);
4239
4240 let dispatcher = build_dispatcher(Rc::clone(&runtime));
4241 for request in requests {
4242 dispatcher.dispatch_and_complete(request).await;
4243 }
4244
4245 while runtime.has_pending() {
4246 runtime.tick().await.expect("tick");
4247 runtime.drain_microtasks().await.expect("microtasks");
4248 }
4249
4250 runtime
4251 .eval(
4252 r#"
4253 if (globalThis.err === null) throw new Error("Promise not rejected");
4254 if (globalThis.err !== "invalid_request") {
4255 throw new Error("Wrong error code: " + globalThis.err);
4256 }
4257 "#,
4258 )
4259 .await
4260 .expect("verify error");
4261 });
4262 }
4263
4264 #[test]
4265 fn dispatcher_session_hostcall_resolves_state_and_set_name() {
4266 futures::executor::block_on(async {
4267 let runtime = Rc::new(
4268 PiJsRuntime::with_clock(DeterministicClock::new(0))
4269 .await
4270 .expect("runtime"),
4271 );
4272
4273 runtime
4274 .eval(
4275 r#"
4276 globalThis.state = null;
4277 globalThis.file = null;
4278 globalThis.nameValue = null;
4279 globalThis.nameSet = false;
4280 pi.session("get_state", {}).then((r) => { globalThis.state = r; });
4281 pi.session("get_file", {}).then((r) => { globalThis.file = r; });
4282 pi.session("get_name", {}).then((r) => { globalThis.nameValue = r; });
4283 pi.session("set_name", { name: "hello" }).then(() => { globalThis.nameSet = true; });
4284 "#,
4285 )
4286 .await
4287 .expect("eval");
4288
4289 let requests = runtime.drain_hostcall_requests();
4290 assert_eq!(requests.len(), 4);
4291
4292 let name = Arc::new(Mutex::new(None));
4293 let state = Arc::new(Mutex::new(serde_json::json!({
4294 "sessionFile": "/tmp/session.jsonl",
4295 "sessionName": "demo",
4296 })));
4297 let session = Arc::new(TestSession {
4298 state: Arc::clone(&state),
4299 messages: Arc::new(Mutex::new(Vec::new())),
4300 entries: Arc::new(Mutex::new(Vec::new())),
4301 branch: Arc::new(Mutex::new(Vec::new())),
4302 name: Arc::clone(&name),
4303 custom_entries: Arc::new(Mutex::new(Vec::new())),
4304 labels: Arc::new(Mutex::new(Vec::new())),
4305 });
4306
4307 let dispatcher = ExtensionDispatcher::new(
4308 Rc::clone(&runtime),
4309 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
4310 Arc::new(HttpConnector::with_defaults()),
4311 session,
4312 Arc::new(NullUiHandler),
4313 PathBuf::from("."),
4314 );
4315
4316 for request in requests {
4317 dispatcher.dispatch_and_complete(request).await;
4318 }
4319
4320 while runtime.has_pending() {
4321 runtime.tick().await.expect("tick");
4322 runtime.drain_microtasks().await.expect("microtasks");
4323 }
4324
4325 let (state_value, file_value, name_value, name_set) = runtime
4326 .with_ctx(|ctx| {
4327 let global = ctx.globals();
4328 let state_js: rquickjs::Value<'_> = global.get("state")?;
4329 let file_js: rquickjs::Value<'_> = global.get("file")?;
4330 let name_js: rquickjs::Value<'_> = global.get("nameValue")?;
4331 let name_set_js: rquickjs::Value<'_> = global.get("nameSet")?;
4332 Ok((
4333 crate::extensions_js::js_to_json(&state_js)?,
4334 crate::extensions_js::js_to_json(&file_js)?,
4335 crate::extensions_js::js_to_json(&name_js)?,
4336 crate::extensions_js::js_to_json(&name_set_js)?,
4337 ))
4338 })
4339 .await
4340 .expect("read globals");
4341
4342 let state_file = state_value
4343 .get("sessionFile")
4344 .and_then(Value::as_str)
4345 .unwrap_or_default();
4346 assert_eq!(state_file, "/tmp/session.jsonl");
4347 assert_eq!(file_value, Value::String("/tmp/session.jsonl".to_string()));
4348 assert_eq!(name_value, Value::String("demo".to_string()));
4349 assert_eq!(name_set, Value::Bool(true));
4350
4351 let name_value = name
4352 .lock()
4353 .unwrap_or_else(std::sync::PoisonError::into_inner)
4354 .clone();
4355 assert_eq!(name_value.as_deref(), Some("hello"));
4356 });
4357 }
4358
4359 #[test]
4360 fn dispatcher_session_hostcall_get_messages_entries_branch() {
4361 futures::executor::block_on(async {
4362 let runtime = Rc::new(
4363 PiJsRuntime::with_clock(DeterministicClock::new(0))
4364 .await
4365 .expect("runtime"),
4366 );
4367
4368 runtime
4369 .eval(
4370 r#"
4371 globalThis.messages = null;
4372 globalThis.entries = null;
4373 globalThis.branch = null;
4374 pi.session("get_messages", {}).then((r) => { globalThis.messages = r; });
4375 pi.session("get_entries", {}).then((r) => { globalThis.entries = r; });
4376 pi.session("get_branch", {}).then((r) => { globalThis.branch = r; });
4377 "#,
4378 )
4379 .await
4380 .expect("eval");
4381
4382 let requests = runtime.drain_hostcall_requests();
4383 assert_eq!(requests.len(), 3);
4384
4385 let message = SessionMessage::Custom {
4386 custom_type: "note".to_string(),
4387 content: "hello".to_string(),
4388 display: true,
4389 details: None,
4390 timestamp: Some(0),
4391 };
4392 let entries = vec![serde_json::json!({ "id": "entry-1", "type": "custom" })];
4393 let branch = vec![serde_json::json!({ "id": "entry-2", "type": "branch" })];
4394
4395 let session = Arc::new(TestSession {
4396 state: Arc::new(Mutex::new(Value::Null)),
4397 messages: Arc::new(Mutex::new(vec![message.clone()])),
4398 entries: Arc::new(Mutex::new(entries.clone())),
4399 branch: Arc::new(Mutex::new(branch.clone())),
4400 name: Arc::new(Mutex::new(None)),
4401 custom_entries: Arc::new(Mutex::new(Vec::new())),
4402 labels: Arc::new(Mutex::new(Vec::new())),
4403 });
4404
4405 let dispatcher = ExtensionDispatcher::new(
4406 Rc::clone(&runtime),
4407 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
4408 Arc::new(HttpConnector::with_defaults()),
4409 session,
4410 Arc::new(NullUiHandler),
4411 PathBuf::from("."),
4412 );
4413
4414 for request in requests {
4415 dispatcher.dispatch_and_complete(request).await;
4416 }
4417
4418 while runtime.has_pending() {
4419 runtime.tick().await.expect("tick");
4420 runtime.drain_microtasks().await.expect("microtasks");
4421 }
4422
4423 let (messages_value, entries_value, branch_value) = runtime
4424 .with_ctx(|ctx| {
4425 let global = ctx.globals();
4426 let messages_js: rquickjs::Value<'_> = global.get("messages")?;
4427 let entries_js: rquickjs::Value<'_> = global.get("entries")?;
4428 let branch_js: rquickjs::Value<'_> = global.get("branch")?;
4429 Ok((
4430 crate::extensions_js::js_to_json(&messages_js)?,
4431 crate::extensions_js::js_to_json(&entries_js)?,
4432 crate::extensions_js::js_to_json(&branch_js)?,
4433 ))
4434 })
4435 .await
4436 .expect("read globals");
4437
4438 let messages_array = messages_value.as_array().expect("messages array");
4439 assert_eq!(messages_array.len(), 1);
4440 assert_eq!(
4441 messages_array[0]
4442 .get("role")
4443 .and_then(Value::as_str)
4444 .unwrap_or_default(),
4445 "custom"
4446 );
4447 assert_eq!(
4448 messages_array[0]
4449 .get("customType")
4450 .and_then(Value::as_str)
4451 .unwrap_or_default(),
4452 "note"
4453 );
4454 assert_eq!(entries_value, Value::Array(entries));
4455 assert_eq!(branch_value, Value::Array(branch));
4456 });
4457 }
4458
4459 #[test]
4460 #[allow(clippy::too_many_lines)]
4461 fn dispatcher_session_hostcall_append_message_and_entry() {
4462 futures::executor::block_on(async {
4463 let runtime = Rc::new(
4464 PiJsRuntime::with_clock(DeterministicClock::new(0))
4465 .await
4466 .expect("runtime"),
4467 );
4468
4469 runtime
4470 .eval(
4471 r#"
4472 globalThis.messageAppended = false;
4473 globalThis.entryAppended = false;
4474 pi.session("append_message", {
4475 message: { role: "custom", customType: "note", content: "hi", display: true }
4476 }).then(() => { globalThis.messageAppended = true; });
4477 pi.session("append_entry", {
4478 customType: "meta",
4479 data: { ok: true }
4480 }).then(() => { globalThis.entryAppended = true; });
4481 "#,
4482 )
4483 .await
4484 .expect("eval");
4485
4486 let requests = runtime.drain_hostcall_requests();
4487 assert_eq!(requests.len(), 2);
4488
4489 let session = Arc::new(TestSession {
4490 state: Arc::new(Mutex::new(Value::Null)),
4491 messages: Arc::new(Mutex::new(Vec::new())),
4492 entries: Arc::new(Mutex::new(Vec::new())),
4493 branch: Arc::new(Mutex::new(Vec::new())),
4494 name: Arc::new(Mutex::new(None)),
4495 custom_entries: Arc::new(Mutex::new(Vec::new())),
4496 labels: Arc::new(Mutex::new(Vec::new())),
4497 });
4498
4499 let dispatcher = ExtensionDispatcher::new(
4500 Rc::clone(&runtime),
4501 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
4502 Arc::new(HttpConnector::with_defaults()),
4503 {
4504 let session_handle: Arc<dyn ExtensionSession + Send + Sync> = session.clone();
4505 session_handle
4506 },
4507 Arc::new(NullUiHandler),
4508 PathBuf::from("."),
4509 );
4510
4511 for request in requests {
4512 dispatcher.dispatch_and_complete(request).await;
4513 }
4514
4515 while runtime.has_pending() {
4516 runtime.tick().await.expect("tick");
4517 runtime.drain_microtasks().await.expect("microtasks");
4518 }
4519
4520 let (message_appended, entry_appended) = runtime
4521 .with_ctx(|ctx| {
4522 let global = ctx.globals();
4523 let message_js: rquickjs::Value<'_> = global.get("messageAppended")?;
4524 let entry_js: rquickjs::Value<'_> = global.get("entryAppended")?;
4525 Ok((
4526 crate::extensions_js::js_to_json(&message_js)?,
4527 crate::extensions_js::js_to_json(&entry_js)?,
4528 ))
4529 })
4530 .await
4531 .expect("read globals");
4532
4533 assert_eq!(message_appended, Value::Bool(true));
4534 assert_eq!(entry_appended, Value::Bool(true));
4535
4536 {
4537 let messages = session
4538 .messages
4539 .lock()
4540 .unwrap_or_else(std::sync::PoisonError::into_inner)
4541 .clone();
4542 assert_eq!(messages.len(), 1);
4543 match &messages[0] {
4544 SessionMessage::Custom {
4545 custom_type,
4546 content,
4547 display,
4548 ..
4549 } => {
4550 assert_eq!(custom_type, "note");
4551 assert_eq!(content, "hi");
4552 assert!(*display);
4553 }
4554 other => assert!(
4555 matches!(other, SessionMessage::Custom { .. }),
4556 "Unexpected message: {other:?}"
4557 ),
4558 }
4559 }
4560
4561 {
4562 let expected = Some(serde_json::json!({ "ok": true }));
4563 let custom_entries = session
4564 .custom_entries
4565 .lock()
4566 .unwrap_or_else(std::sync::PoisonError::into_inner)
4567 .clone();
4568 assert_eq!(custom_entries.len(), 1);
4569 assert_eq!(custom_entries[0].0, "meta");
4570 assert_eq!(custom_entries[0].1, expected);
4571 drop(custom_entries);
4572 }
4573 });
4574 }
4575
4576 #[test]
4577 fn dispatcher_session_hostcall_unknown_op_rejects_promise() {
4578 futures::executor::block_on(async {
4579 let runtime = Rc::new(
4580 PiJsRuntime::with_clock(DeterministicClock::new(0))
4581 .await
4582 .expect("runtime"),
4583 );
4584
4585 runtime
4586 .eval(
4587 r#"
4588 globalThis.err = null;
4589 pi.session("nope", {}).catch((e) => { globalThis.err = e.code; });
4590 "#,
4591 )
4592 .await
4593 .expect("eval");
4594
4595 let requests = runtime.drain_hostcall_requests();
4596 assert_eq!(requests.len(), 1);
4597
4598 let dispatcher = build_dispatcher(Rc::clone(&runtime));
4599 for request in requests {
4600 dispatcher.dispatch_and_complete(request).await;
4601 }
4602
4603 while runtime.has_pending() {
4604 runtime.tick().await.expect("tick");
4605 runtime.drain_microtasks().await.expect("microtasks");
4606 }
4607
4608 let err_value = runtime
4609 .with_ctx(|ctx| {
4610 let global = ctx.globals();
4611 let err_js: rquickjs::Value<'_> = global.get("err")?;
4612 crate::extensions_js::js_to_json(&err_js)
4613 })
4614 .await
4615 .expect("read globals");
4616
4617 assert_eq!(err_value, Value::String("invalid_request".to_string()));
4618 });
4619 }
4620
4621 #[test]
4622 fn dispatcher_session_hostcall_append_message_invalid_rejects_promise() {
4623 futures::executor::block_on(async {
4624 let runtime = Rc::new(
4625 PiJsRuntime::with_clock(DeterministicClock::new(0))
4626 .await
4627 .expect("runtime"),
4628 );
4629
4630 runtime
4631 .eval(
4632 r#"
4633 globalThis.err = null;
4634 pi.session("append_message", { message: { nope: 1 } })
4635 .catch((e) => { globalThis.err = e.code; });
4636 "#,
4637 )
4638 .await
4639 .expect("eval");
4640
4641 let requests = runtime.drain_hostcall_requests();
4642 assert_eq!(requests.len(), 1);
4643
4644 let dispatcher = build_dispatcher(Rc::clone(&runtime));
4645 for request in requests {
4646 dispatcher.dispatch_and_complete(request).await;
4647 }
4648
4649 while runtime.has_pending() {
4650 runtime.tick().await.expect("tick");
4651 runtime.drain_microtasks().await.expect("microtasks");
4652 }
4653
4654 let err_value = runtime
4655 .with_ctx(|ctx| {
4656 let global = ctx.globals();
4657 let err_js: rquickjs::Value<'_> = global.get("err")?;
4658 crate::extensions_js::js_to_json(&err_js)
4659 })
4660 .await
4661 .expect("read globals");
4662
4663 assert_eq!(err_value, Value::String("invalid_request".to_string()));
4664 });
4665 }
4666
4667 #[test]
4668 #[cfg(unix)]
4669 fn dispatcher_exec_hostcall_executes_and_resolves_promise() {
4670 futures::executor::block_on(async {
4671 let runtime = Rc::new(
4672 PiJsRuntime::with_clock(DeterministicClock::new(0))
4673 .await
4674 .expect("runtime"),
4675 );
4676
4677 runtime
4678 .eval(
4679 r#"
4680 globalThis.result = null;
4681 pi.exec("sh", ["-c", "printf hello"], {})
4682 .then((r) => { globalThis.result = r; });
4683 "#,
4684 )
4685 .await
4686 .expect("eval");
4687
4688 let requests = runtime.drain_hostcall_requests();
4689 assert_eq!(requests.len(), 1);
4690
4691 let dispatcher = build_dispatcher(Rc::clone(&runtime));
4692 for request in requests {
4693 dispatcher.dispatch_and_complete(request).await;
4694 }
4695
4696 runtime.tick().await.expect("tick");
4697
4698 runtime
4699 .eval(
4700 r#"
4701 if (globalThis.result === null) throw new Error("Promise not resolved");
4702 if (globalThis.result.stdout !== "hello") {
4703 throw new Error("Wrong stdout: " + JSON.stringify(globalThis.result));
4704 }
4705 if (globalThis.result.code !== 0) {
4706 throw new Error("Wrong exit code: " + JSON.stringify(globalThis.result));
4707 }
4708 if (globalThis.result.killed !== false) {
4709 throw new Error("Unexpected killed flag: " + JSON.stringify(globalThis.result));
4710 }
4711 "#,
4712 )
4713 .await
4714 .expect("verify result");
4715 });
4716 }
4717
4718 #[test]
4719 #[cfg(unix)]
4720 fn dispatcher_exec_hostcall_command_not_found_rejects_promise() {
4721 futures::executor::block_on(async {
4722 let runtime = Rc::new(
4723 PiJsRuntime::with_clock(DeterministicClock::new(0))
4724 .await
4725 .expect("runtime"),
4726 );
4727
4728 runtime
4729 .eval(
4730 r#"
4731 globalThis.err = null;
4732 pi.exec("definitely_not_a_real_command", [], {})
4733 .catch((e) => { globalThis.err = e.code; });
4734 "#,
4735 )
4736 .await
4737 .expect("eval");
4738
4739 let requests = runtime.drain_hostcall_requests();
4740 assert_eq!(requests.len(), 1);
4741
4742 let dispatcher = build_dispatcher(Rc::clone(&runtime));
4743 for request in requests {
4744 dispatcher.dispatch_and_complete(request).await;
4745 }
4746
4747 runtime.tick().await.expect("tick");
4748
4749 runtime
4750 .eval(
4751 r#"
4752 if (globalThis.err === null) throw new Error("Promise not rejected");
4753 if (globalThis.err !== "io") {
4754 throw new Error("Wrong error code: " + globalThis.err);
4755 }
4756 "#,
4757 )
4758 .await
4759 .expect("verify error");
4760 });
4761 }
4762
4763 #[test]
4764 #[cfg(unix)]
4765 fn dispatcher_exec_hostcall_streaming_callback_delivers_chunks_and_final_result() {
4766 futures::executor::block_on(async {
4767 let runtime = Rc::new(
4768 PiJsRuntime::with_clock(DeterministicClock::new(0))
4769 .await
4770 .expect("runtime"),
4771 );
4772
4773 runtime
4774 .eval(
4775 r#"
4776 globalThis.chunks = [];
4777 globalThis.finalResult = null;
4778 pi.exec("sh", ["-c", "printf 'out-1\n'; printf 'err-1\n' 1>&2; printf 'out-2\n'"], {
4779 stream: true,
4780 onChunk: (chunk, isFinal) => {
4781 globalThis.chunks.push({ chunk, isFinal });
4782 },
4783 }).then((r) => { globalThis.finalResult = r; });
4784 "#,
4785 )
4786 .await
4787 .expect("eval");
4788
4789 let requests = runtime.drain_hostcall_requests();
4790 assert_eq!(requests.len(), 1);
4791
4792 let dispatcher = build_dispatcher(Rc::clone(&runtime));
4793 for request in requests {
4794 dispatcher.dispatch_and_complete(request).await;
4795 }
4796
4797 while runtime.has_pending() {
4798 runtime.tick().await.expect("tick");
4799 runtime.drain_microtasks().await.expect("microtasks");
4800 }
4801
4802 runtime
4803 .eval(
4804 r#"
4805 if (!Array.isArray(globalThis.chunks) || globalThis.chunks.length < 3) {
4806 throw new Error("Expected stream chunks, got: " + JSON.stringify(globalThis.chunks));
4807 }
4808 const sawStdout = globalThis.chunks.some((entry) => entry.chunk && entry.chunk.stdout && entry.chunk.stdout.includes("out-1"));
4809 if (!sawStdout) {
4810 throw new Error("Missing stdout chunk: " + JSON.stringify(globalThis.chunks));
4811 }
4812 const sawStderr = globalThis.chunks.some((entry) => entry.chunk && entry.chunk.stderr && entry.chunk.stderr.includes("err-1"));
4813 if (!sawStderr) {
4814 throw new Error("Missing stderr chunk: " + JSON.stringify(globalThis.chunks));
4815 }
4816 const finalEntry = globalThis.chunks[globalThis.chunks.length - 1];
4817 if (!finalEntry || finalEntry.isFinal !== true) {
4818 throw new Error("Missing final chunk marker: " + JSON.stringify(globalThis.chunks));
4819 }
4820 if (globalThis.finalResult === null) {
4821 throw new Error("Promise not resolved");
4822 }
4823 if (globalThis.finalResult.code !== 0) {
4824 throw new Error("Wrong exit code: " + JSON.stringify(globalThis.finalResult));
4825 }
4826 if (globalThis.finalResult.killed !== false) {
4827 throw new Error("Unexpected killed flag: " + JSON.stringify(globalThis.finalResult));
4828 }
4829 "#,
4830 )
4831 .await
4832 .expect("verify stream callback result");
4833 });
4834 }
4835
4836 #[test]
4837 #[cfg(unix)]
4838 fn dispatcher_exec_hostcall_streaming_async_iterator_delivers_chunks_in_order() {
4839 futures::executor::block_on(async {
4840 let runtime = Rc::new(
4841 PiJsRuntime::with_clock(DeterministicClock::new(0))
4842 .await
4843 .expect("runtime"),
4844 );
4845
4846 runtime
4847 .eval(
4848 r#"
4849 globalThis.iterChunks = [];
4850 globalThis.iterDone = false;
4851 (async () => {
4852 const stream = pi.exec("sh", ["-c", "printf 'a\n'; printf 'b\n'"], { stream: true });
4853 for await (const chunk of stream) {
4854 globalThis.iterChunks.push(chunk);
4855 }
4856 globalThis.iterDone = true;
4857 })();
4858 "#,
4859 )
4860 .await
4861 .expect("eval");
4862
4863 let requests = runtime.drain_hostcall_requests();
4864 assert_eq!(requests.len(), 1);
4865
4866 let dispatcher = build_dispatcher(Rc::clone(&runtime));
4867 for request in requests {
4868 dispatcher.dispatch_and_complete(request).await;
4869 }
4870
4871 while runtime.has_pending() {
4872 runtime.tick().await.expect("tick");
4873 runtime.drain_microtasks().await.expect("microtasks");
4874 }
4875
4876 runtime
4877 .eval(
4878 r#"
4879 if (globalThis.iterDone !== true) {
4880 throw new Error("Async iterator did not finish");
4881 }
4882 if (!Array.isArray(globalThis.iterChunks) || globalThis.iterChunks.length < 2) {
4883 throw new Error("Missing stream chunks: " + JSON.stringify(globalThis.iterChunks));
4884 }
4885 const stdout = globalThis.iterChunks
4886 .map((chunk) => (chunk && typeof chunk.stdout === "string" ? chunk.stdout : ""))
4887 .join("");
4888 if (stdout !== "a\nb\n") {
4889 throw new Error("Unexpected streamed stdout aggregate: " + JSON.stringify(globalThis.iterChunks));
4890 }
4891 const finalChunk = globalThis.iterChunks[globalThis.iterChunks.length - 1];
4892 if (!finalChunk || finalChunk.code !== 0 || finalChunk.killed !== false) {
4893 throw new Error("Unexpected final chunk: " + JSON.stringify(finalChunk));
4894 }
4895 "#,
4896 )
4897 .await
4898 .expect("verify async iterator result");
4899 });
4900 }
4901
4902 #[test]
4903 #[cfg(unix)]
4904 fn dispatcher_exec_hostcall_handles_invalid_utf8() {
4905 futures::executor::block_on(async {
4906 let runtime = Rc::new(
4907 PiJsRuntime::with_clock(DeterministicClock::new(0))
4908 .await
4909 .expect("runtime"),
4910 );
4911
4912 runtime
4916 .eval(
4917 r#"
4918 globalThis.output = "";
4919 globalThis.outputDone = false;
4920 (async () => {
4921 const stream = pi.exec("sh", ["-c", "printf 'a\\377b'"], { stream: true });
4922 for await (const chunk of stream) {
4923 if (chunk.stdout) globalThis.output += chunk.stdout;
4924 }
4925 globalThis.outputDone = true;
4926 })();
4927 "#,
4928 )
4929 .await
4930 .expect("eval");
4931
4932 let requests = runtime.drain_hostcall_requests();
4933 assert_eq!(requests.len(), 1);
4934
4935 let dispatcher = build_dispatcher(Rc::clone(&runtime));
4936 for request in requests {
4937 dispatcher.dispatch_and_complete(request).await;
4938 }
4939
4940 while runtime.has_pending() {
4941 runtime.tick().await.expect("tick");
4942 runtime.drain_microtasks().await.expect("microtasks");
4943 }
4944
4945 runtime
4946 .eval(
4947 r#"
4948 if (globalThis.outputDone !== true) {
4949 throw new Error("Streaming output collection did not finish");
4950 }
4951 // \uFFFD is the replacement character
4952 if (globalThis.output !== "a\uFFFDb") {
4953 throw new Error("Expected 'a\\uFFFDb', got: " + globalThis.output + " (len " + globalThis.output.length + ")");
4954 }
4955 "#,
4956 )
4957 .await
4958 .expect("verify invalid utf8 handling");
4959 });
4960 }
4961
4962 #[test]
4963 #[cfg(unix)]
4964 #[ignore = "flaky on CI: timing-sensitive 500ms exec timeout with futures::executor"]
4965 fn dispatcher_exec_hostcall_streaming_timeout_marks_final_chunk_killed() {
4966 futures::executor::block_on(async {
4967 let runtime = Rc::new(
4968 PiJsRuntime::with_clock(DeterministicClock::new(0))
4969 .await
4970 .expect("runtime"),
4971 );
4972
4973 runtime
4974 .eval(
4975 r#"
4976 globalThis.timeoutChunks = [];
4977 globalThis.timeoutResult = null;
4978 globalThis.timeoutError = null;
4979 pi.exec("sh", ["-c", "printf 'start\n'; sleep 5; printf 'late\n'"], {
4980 stream: true,
4981 timeoutMs: 500,
4982 onChunk: (chunk, isFinal) => {
4983 globalThis.timeoutChunks.push({ chunk, isFinal });
4984 },
4985 })
4986 .then((r) => { globalThis.timeoutResult = r; })
4987 .catch((e) => { globalThis.timeoutError = e; });
4988 "#,
4989 )
4990 .await
4991 .expect("eval");
4992
4993 let requests = runtime.drain_hostcall_requests();
4994 assert_eq!(requests.len(), 1);
4995
4996 let dispatcher = build_dispatcher(Rc::clone(&runtime));
4997 for request in requests {
4998 dispatcher.dispatch_and_complete(request).await;
4999 }
5000
5001 while runtime.has_pending() {
5002 runtime.tick().await.expect("tick");
5003 runtime.drain_microtasks().await.expect("microtasks");
5004 }
5005
5006 runtime
5007 .eval(
5008 r#"
5009 if (globalThis.timeoutError !== null) {
5010 throw new Error("Unexpected timeout error: " + JSON.stringify(globalThis.timeoutError));
5011 }
5012 if (globalThis.timeoutResult === null) {
5013 throw new Error("Timeout stream promise not resolved");
5014 }
5015 if (globalThis.timeoutResult.killed !== true) {
5016 throw new Error("Expected killed=true for timeout stream: " + JSON.stringify(globalThis.timeoutResult));
5017 }
5018 const finalEntry = globalThis.timeoutChunks[globalThis.timeoutChunks.length - 1];
5019 if (!finalEntry || finalEntry.isFinal !== true) {
5020 throw new Error("Missing final timeout chunk marker: " + JSON.stringify(globalThis.timeoutChunks));
5021 }
5022 const sawLateOutput = globalThis.timeoutChunks.some((entry) =>
5023 entry.chunk && entry.chunk.stdout && entry.chunk.stdout.includes("late")
5024 );
5025 if (sawLateOutput) {
5026 throw new Error("Process output after timeout kill: " + JSON.stringify(globalThis.timeoutChunks));
5027 }
5028 "#,
5029 )
5030 .await
5031 .expect("verify timeout stream result");
5032 });
5033 }
5034
5035 #[test]
5036 fn dispatcher_http_hostcall_executes_and_resolves_promise() {
5037 futures::executor::block_on(async {
5038 let addr = spawn_http_server("hello");
5039 let url = format!("http://{addr}/test");
5040
5041 let runtime = Rc::new(
5042 PiJsRuntime::with_clock(DeterministicClock::new(0))
5043 .await
5044 .expect("runtime"),
5045 );
5046
5047 let script = format!(
5048 r#"
5049 globalThis.result = null;
5050 pi.http({{ url: "{url}", method: "GET" }})
5051 .then((r) => {{ globalThis.result = r; }});
5052 "#
5053 );
5054 runtime.eval(&script).await.expect("eval");
5055
5056 let requests = runtime.drain_hostcall_requests();
5057 assert_eq!(requests.len(), 1);
5058
5059 let http_connector = HttpConnector::new(HttpConnectorConfig {
5060 require_tls: false,
5061 ..Default::default()
5062 });
5063 let dispatcher = ExtensionDispatcher::new(
5064 Rc::clone(&runtime),
5065 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
5066 Arc::new(http_connector),
5067 Arc::new(NullSession),
5068 Arc::new(NullUiHandler),
5069 PathBuf::from("."),
5070 );
5071
5072 for request in requests {
5073 dispatcher.dispatch_and_complete(request).await;
5074 }
5075
5076 runtime.tick().await.expect("tick");
5077
5078 runtime
5079 .eval(
5080 r#"
5081 if (globalThis.result === null) throw new Error("Promise not resolved");
5082 if (globalThis.result.status !== 200) {
5083 throw new Error("Wrong status: " + globalThis.result.status);
5084 }
5085 if (globalThis.result.body !== "hello") {
5086 throw new Error("Wrong body: " + globalThis.result.body);
5087 }
5088 "#,
5089 )
5090 .await
5091 .expect("verify result");
5092 });
5093 }
5094
5095 #[test]
5096 fn dispatcher_http_hostcall_invalid_method_rejects_promise() {
5097 futures::executor::block_on(async {
5098 let runtime = Rc::new(
5099 PiJsRuntime::with_clock(DeterministicClock::new(0))
5100 .await
5101 .expect("runtime"),
5102 );
5103
5104 runtime
5105 .eval(
5106 r#"
5107 globalThis.err = null;
5108 pi.http({ url: "https://example.com", method: "PUT" })
5109 .catch((e) => { globalThis.err = e.code; });
5110 "#,
5111 )
5112 .await
5113 .expect("eval");
5114
5115 let requests = runtime.drain_hostcall_requests();
5116 assert_eq!(requests.len(), 1);
5117
5118 let http_connector = HttpConnector::new(HttpConnectorConfig {
5119 require_tls: false,
5120 ..Default::default()
5121 });
5122 let dispatcher = ExtensionDispatcher::new(
5123 Rc::clone(&runtime),
5124 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
5125 Arc::new(http_connector),
5126 Arc::new(NullSession),
5127 Arc::new(NullUiHandler),
5128 PathBuf::from("."),
5129 );
5130
5131 for request in requests {
5132 dispatcher.dispatch_and_complete(request).await;
5133 }
5134
5135 runtime.tick().await.expect("tick");
5136
5137 runtime
5138 .eval(
5139 r#"
5140 if (globalThis.err === null) throw new Error("Promise not rejected");
5141 if (globalThis.err !== "invalid_request") {
5142 throw new Error("Wrong error code: " + globalThis.err);
5143 }
5144 "#,
5145 )
5146 .await
5147 .expect("verify error");
5148 });
5149 }
5150
5151 #[test]
5152 fn dispatcher_ui_hostcall_executes_and_resolves_promise() {
5153 futures::executor::block_on(async {
5154 let runtime = Rc::new(
5155 PiJsRuntime::with_clock(DeterministicClock::new(0))
5156 .await
5157 .expect("runtime"),
5158 );
5159
5160 runtime
5161 .eval(
5162 r#"
5163 globalThis.uiResult = null;
5164 pi.ui("confirm", { title: "Confirm?" }).then((r) => { globalThis.uiResult = r; });
5165 "#,
5166 )
5167 .await
5168 .expect("eval");
5169
5170 let requests = runtime.drain_hostcall_requests();
5171 assert_eq!(requests.len(), 1);
5172
5173 let captured = Arc::new(Mutex::new(Vec::new()));
5174 let dispatcher = ExtensionDispatcher::new(
5175 Rc::clone(&runtime),
5176 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
5177 Arc::new(HttpConnector::with_defaults()),
5178 Arc::new(NullSession),
5179 Arc::new(TestUiHandler {
5180 captured: Arc::clone(&captured),
5181 response_value: serde_json::json!({ "ok": true }),
5182 }),
5183 PathBuf::from("."),
5184 );
5185
5186 for request in requests {
5187 dispatcher.dispatch_and_complete(request).await;
5188 }
5189
5190 runtime.tick().await.expect("tick");
5191
5192 runtime
5193 .eval(
5194 r#"
5195 if (!globalThis.uiResult || globalThis.uiResult.ok !== true) {
5196 throw new Error("Wrong UI result: " + JSON.stringify(globalThis.uiResult));
5197 }
5198 "#,
5199 )
5200 .await
5201 .expect("verify result");
5202
5203 let seen = captured
5204 .lock()
5205 .unwrap_or_else(std::sync::PoisonError::into_inner)
5206 .clone();
5207 assert_eq!(seen.len(), 1);
5208 assert_eq!(seen[0].method, "confirm");
5209 });
5210 }
5211
5212 #[test]
5213 fn dispatcher_extension_ui_set_status_includes_text_field() {
5214 futures::executor::block_on(async {
5215 let runtime = Rc::new(
5216 PiJsRuntime::with_clock(DeterministicClock::new(0))
5217 .await
5218 .expect("runtime"),
5219 );
5220
5221 runtime
5222 .eval(
5223 r#"
5224 const ui = __pi_make_extension_ui(true);
5225 ui.setStatus("key", "hello");
5226 "#,
5227 )
5228 .await
5229 .expect("eval");
5230
5231 let requests = runtime.drain_hostcall_requests();
5232 assert_eq!(requests.len(), 1);
5233
5234 let captured = Arc::new(Mutex::new(Vec::new()));
5235 let dispatcher = ExtensionDispatcher::new(
5236 Rc::clone(&runtime),
5237 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
5238 Arc::new(HttpConnector::with_defaults()),
5239 Arc::new(NullSession),
5240 Arc::new(TestUiHandler {
5241 captured: Arc::clone(&captured),
5242 response_value: Value::Null,
5243 }),
5244 PathBuf::from("."),
5245 );
5246
5247 for request in requests {
5248 dispatcher.dispatch_and_complete(request).await;
5249 }
5250
5251 runtime.tick().await.expect("tick");
5252
5253 let seen = captured
5254 .lock()
5255 .unwrap_or_else(std::sync::PoisonError::into_inner)
5256 .clone();
5257 assert_eq!(seen.len(), 1);
5258 assert_eq!(seen[0].method, "setStatus");
5259 assert_eq!(
5260 seen[0].payload.get("statusKey").and_then(Value::as_str),
5261 Some("key")
5262 );
5263 assert_eq!(
5264 seen[0].payload.get("statusText").and_then(Value::as_str),
5265 Some("hello")
5266 );
5267 assert_eq!(
5268 seen[0].payload.get("text").and_then(Value::as_str),
5269 Some("hello")
5270 );
5271 });
5272 }
5273
5274 #[test]
5275 fn dispatcher_extension_ui_set_widget_includes_widget_lines_and_content() {
5276 futures::executor::block_on(async {
5277 let runtime = Rc::new(
5278 PiJsRuntime::with_clock(DeterministicClock::new(0))
5279 .await
5280 .expect("runtime"),
5281 );
5282
5283 runtime
5284 .eval(
5285 r#"
5286 const ui = __pi_make_extension_ui(true);
5287 ui.setWidget("widget", ["a", "b"]);
5288 "#,
5289 )
5290 .await
5291 .expect("eval");
5292
5293 let requests = runtime.drain_hostcall_requests();
5294 assert_eq!(requests.len(), 1);
5295
5296 let captured = Arc::new(Mutex::new(Vec::new()));
5297 let dispatcher = ExtensionDispatcher::new(
5298 Rc::clone(&runtime),
5299 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
5300 Arc::new(HttpConnector::with_defaults()),
5301 Arc::new(NullSession),
5302 Arc::new(TestUiHandler {
5303 captured: Arc::clone(&captured),
5304 response_value: Value::Null,
5305 }),
5306 PathBuf::from("."),
5307 );
5308
5309 for request in requests {
5310 dispatcher.dispatch_and_complete(request).await;
5311 }
5312
5313 runtime.tick().await.expect("tick");
5314
5315 let seen = captured
5316 .lock()
5317 .unwrap_or_else(std::sync::PoisonError::into_inner)
5318 .clone();
5319 assert_eq!(seen.len(), 1);
5320 assert_eq!(seen[0].method, "setWidget");
5321 assert_eq!(
5322 seen[0].payload.get("widgetKey").and_then(Value::as_str),
5323 Some("widget")
5324 );
5325 assert_eq!(
5326 seen[0].payload.get("content").and_then(Value::as_str),
5327 Some("a\nb")
5328 );
5329 assert_eq!(
5330 seen[0].payload.get("widgetLines").and_then(Value::as_array),
5331 seen[0].payload.get("lines").and_then(Value::as_array)
5332 );
5333 });
5334 }
5335
5336 #[test]
5337 fn dispatcher_events_hostcall_rejects_promise() {
5338 futures::executor::block_on(async {
5339 let runtime = Rc::new(
5340 PiJsRuntime::with_clock(DeterministicClock::new(0))
5341 .await
5342 .expect("runtime"),
5343 );
5344
5345 runtime
5346 .eval(
5347 r#"
5348 globalThis.err = null;
5349 pi.events("setActiveTools", { tools: ["read"] })
5350 .catch((e) => { globalThis.err = e.code; });
5351 "#,
5352 )
5353 .await
5354 .expect("eval");
5355
5356 let requests = runtime.drain_hostcall_requests();
5357 assert_eq!(requests.len(), 1);
5358
5359 let dispatcher = build_dispatcher(Rc::clone(&runtime));
5360 for request in requests {
5361 dispatcher.dispatch_and_complete(request).await;
5362 }
5363
5364 runtime.tick().await.expect("tick");
5365
5366 runtime
5367 .eval(
5368 r#"
5369 if (globalThis.err === null) throw new Error("Promise not rejected");
5370 if (globalThis.err !== "invalid_request") {
5371 throw new Error("Wrong error code: " + globalThis.err);
5372 }
5373 "#,
5374 )
5375 .await
5376 .expect("verify error");
5377 });
5378 }
5379
5380 #[test]
5381 fn dispatcher_events_list_returns_registered_hooks() {
5382 futures::executor::block_on(async {
5383 let runtime = Rc::new(
5384 PiJsRuntime::with_clock(DeterministicClock::new(0))
5385 .await
5386 .expect("runtime"),
5387 );
5388
5389 runtime
5390 .eval(
5391 r#"
5392 globalThis.eventsList = null;
5393 __pi_begin_extension("ext.a", { name: "ext.a" });
5394 pi.on("custom_event", (_payload, _ctx) => {});
5395 pi.events("list", {}).then((r) => { globalThis.eventsList = r; });
5396 __pi_end_extension();
5397 "#,
5398 )
5399 .await
5400 .expect("eval");
5401
5402 let requests = runtime.drain_hostcall_requests();
5403 assert_eq!(requests.len(), 1);
5404
5405 let dispatcher = build_dispatcher(Rc::clone(&runtime));
5406 for request in requests {
5407 dispatcher.dispatch_and_complete(request).await;
5408 }
5409
5410 runtime.tick().await.expect("tick");
5411
5412 runtime
5413 .eval(
5414 r#"
5415 if (!globalThis.eventsList) throw new Error("Promise not resolved");
5416 const events = globalThis.eventsList.events;
5417 if (!Array.isArray(events)) throw new Error("Missing events array");
5418 if (events.length !== 1 || events[0] !== "custom_event") {
5419 throw new Error("Wrong events list: " + JSON.stringify(events));
5420 }
5421 "#,
5422 )
5423 .await
5424 .expect("verify list");
5425 });
5426 }
5427
5428 #[test]
5429 fn dispatcher_session_set_model_resolves_and_persists() {
5430 futures::executor::block_on(async {
5431 let runtime = Rc::new(
5432 PiJsRuntime::with_clock(DeterministicClock::new(0))
5433 .await
5434 .expect("runtime"),
5435 );
5436
5437 runtime
5438 .eval(
5439 r#"
5440 globalThis.setResult = null;
5441 pi.session("set_model", { provider: "anthropic", modelId: "claude-sonnet-4-20250514" })
5442 .then((r) => { globalThis.setResult = r; });
5443 "#,
5444 )
5445 .await
5446 .expect("eval");
5447
5448 let requests = runtime.drain_hostcall_requests();
5449 assert_eq!(requests.len(), 1);
5450
5451 let state = Arc::new(Mutex::new(serde_json::json!({})));
5452 let session = Arc::new(TestSession {
5453 state: Arc::clone(&state),
5454 messages: Arc::new(Mutex::new(Vec::new())),
5455 entries: Arc::new(Mutex::new(Vec::new())),
5456 branch: Arc::new(Mutex::new(Vec::new())),
5457 name: Arc::new(Mutex::new(None)),
5458 custom_entries: Arc::new(Mutex::new(Vec::new())),
5459 labels: Arc::new(Mutex::new(Vec::new())),
5460 });
5461
5462 let dispatcher = ExtensionDispatcher::new(
5463 Rc::clone(&runtime),
5464 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
5465 Arc::new(HttpConnector::with_defaults()),
5466 session,
5467 Arc::new(NullUiHandler),
5468 PathBuf::from("."),
5469 );
5470
5471 for request in requests {
5472 dispatcher.dispatch_and_complete(request).await;
5473 }
5474
5475 while runtime.has_pending() {
5476 runtime.tick().await.expect("tick");
5477 runtime.drain_microtasks().await.expect("microtasks");
5478 }
5479
5480 runtime
5481 .eval(
5482 r#"
5483 if (globalThis.setResult !== true) {
5484 throw new Error("set_model should resolve to true, got: " + JSON.stringify(globalThis.setResult));
5485 }
5486 "#,
5487 )
5488 .await
5489 .expect("verify set_model result");
5490
5491 let final_state = state
5492 .lock()
5493 .unwrap_or_else(std::sync::PoisonError::into_inner)
5494 .clone();
5495 assert_eq!(
5496 final_state.get("provider").and_then(Value::as_str),
5497 Some("anthropic")
5498 );
5499 assert_eq!(
5500 final_state.get("modelId").and_then(Value::as_str),
5501 Some("claude-sonnet-4-20250514")
5502 );
5503 });
5504 }
5505
5506 #[test]
5507 fn dispatcher_session_get_model_resolves_provider_and_model_id() {
5508 futures::executor::block_on(async {
5509 let runtime = Rc::new(
5510 PiJsRuntime::with_clock(DeterministicClock::new(0))
5511 .await
5512 .expect("runtime"),
5513 );
5514
5515 runtime
5516 .eval(
5517 r#"
5518 globalThis.model = null;
5519 pi.session("get_model", {}).then((r) => { globalThis.model = r; });
5520 "#,
5521 )
5522 .await
5523 .expect("eval");
5524
5525 let requests = runtime.drain_hostcall_requests();
5526 assert_eq!(requests.len(), 1);
5527
5528 let state = Arc::new(Mutex::new(serde_json::json!({
5529 "provider": "openai",
5530 "modelId": "gpt-4o",
5531 })));
5532 let session = Arc::new(TestSession {
5533 state: Arc::clone(&state),
5534 messages: Arc::new(Mutex::new(Vec::new())),
5535 entries: Arc::new(Mutex::new(Vec::new())),
5536 branch: Arc::new(Mutex::new(Vec::new())),
5537 name: Arc::new(Mutex::new(None)),
5538 custom_entries: Arc::new(Mutex::new(Vec::new())),
5539 labels: Arc::new(Mutex::new(Vec::new())),
5540 });
5541
5542 let dispatcher = ExtensionDispatcher::new(
5543 Rc::clone(&runtime),
5544 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
5545 Arc::new(HttpConnector::with_defaults()),
5546 session,
5547 Arc::new(NullUiHandler),
5548 PathBuf::from("."),
5549 );
5550
5551 for request in requests {
5552 dispatcher.dispatch_and_complete(request).await;
5553 }
5554
5555 while runtime.has_pending() {
5556 runtime.tick().await.expect("tick");
5557 runtime.drain_microtasks().await.expect("microtasks");
5558 }
5559
5560 runtime
5561 .eval(
5562 r#"
5563 if (!globalThis.model) throw new Error("get_model not resolved");
5564 if (globalThis.model.provider !== "openai") {
5565 throw new Error("Wrong provider: " + globalThis.model.provider);
5566 }
5567 if (globalThis.model.modelId !== "gpt-4o") {
5568 throw new Error("Wrong modelId: " + globalThis.model.modelId);
5569 }
5570 "#,
5571 )
5572 .await
5573 .expect("verify get_model result");
5574 });
5575 }
5576
5577 #[test]
5578 fn dispatcher_session_set_model_missing_fields_rejects() {
5579 futures::executor::block_on(async {
5580 let runtime = Rc::new(
5581 PiJsRuntime::with_clock(DeterministicClock::new(0))
5582 .await
5583 .expect("runtime"),
5584 );
5585
5586 runtime
5587 .eval(
5588 r#"
5589 globalThis.errNoProvider = null;
5590 globalThis.errNoModelId = null;
5591 globalThis.errEmpty = null;
5592 pi.session("set_model", { modelId: "claude-sonnet-4-20250514" })
5593 .catch((e) => { globalThis.errNoProvider = e.code; });
5594 pi.session("set_model", { provider: "anthropic" })
5595 .catch((e) => { globalThis.errNoModelId = e.code; });
5596 pi.session("set_model", {})
5597 .catch((e) => { globalThis.errEmpty = e.code; });
5598 "#,
5599 )
5600 .await
5601 .expect("eval");
5602
5603 let requests = runtime.drain_hostcall_requests();
5604 assert_eq!(requests.len(), 3);
5605
5606 let dispatcher = build_dispatcher(Rc::clone(&runtime));
5607 for request in requests {
5608 dispatcher.dispatch_and_complete(request).await;
5609 }
5610
5611 while runtime.has_pending() {
5612 runtime.tick().await.expect("tick");
5613 runtime.drain_microtasks().await.expect("microtasks");
5614 }
5615
5616 runtime
5617 .eval(
5618 r#"
5619 if (globalThis.errNoProvider !== "invalid_request") {
5620 throw new Error("Missing provider should reject: " + globalThis.errNoProvider);
5621 }
5622 if (globalThis.errNoModelId !== "invalid_request") {
5623 throw new Error("Missing modelId should reject: " + globalThis.errNoModelId);
5624 }
5625 if (globalThis.errEmpty !== "invalid_request") {
5626 throw new Error("Empty payload should reject: " + globalThis.errEmpty);
5627 }
5628 "#,
5629 )
5630 .await
5631 .expect("verify validation errors");
5632 });
5633 }
5634
5635 #[test]
5636 fn dispatcher_session_set_then_get_model_round_trip() {
5637 futures::executor::block_on(async {
5638 let runtime = Rc::new(
5639 PiJsRuntime::with_clock(DeterministicClock::new(0))
5640 .await
5641 .expect("runtime"),
5642 );
5643
5644 runtime
5646 .eval(
5647 r#"
5648 globalThis.setDone = false;
5649 pi.session("set_model", { provider: "gemini", modelId: "gemini-2.0-flash" })
5650 .then(() => { globalThis.setDone = true; });
5651 "#,
5652 )
5653 .await
5654 .expect("eval set");
5655
5656 let requests = runtime.drain_hostcall_requests();
5657 assert_eq!(requests.len(), 1);
5658
5659 let state = Arc::new(Mutex::new(serde_json::json!({})));
5660 let session = Arc::new(TestSession {
5661 state: Arc::clone(&state),
5662 messages: Arc::new(Mutex::new(Vec::new())),
5663 entries: Arc::new(Mutex::new(Vec::new())),
5664 branch: Arc::new(Mutex::new(Vec::new())),
5665 name: Arc::new(Mutex::new(None)),
5666 custom_entries: Arc::new(Mutex::new(Vec::new())),
5667 labels: Arc::new(Mutex::new(Vec::new())),
5668 });
5669
5670 let dispatcher = ExtensionDispatcher::new(
5671 Rc::clone(&runtime),
5672 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
5673 Arc::new(HttpConnector::with_defaults()),
5674 session as Arc<dyn ExtensionSession + Send + Sync>,
5675 Arc::new(NullUiHandler),
5676 PathBuf::from("."),
5677 );
5678
5679 for request in requests {
5680 dispatcher.dispatch_and_complete(request).await;
5681 }
5682
5683 while runtime.has_pending() {
5684 runtime.tick().await.expect("tick");
5685 runtime.drain_microtasks().await.expect("microtasks");
5686 }
5687
5688 runtime
5690 .eval(
5691 r#"
5692 globalThis.model = null;
5693 pi.session("get_model", {}).then((r) => { globalThis.model = r; });
5694 "#,
5695 )
5696 .await
5697 .expect("eval get");
5698
5699 let requests = runtime.drain_hostcall_requests();
5700 assert_eq!(requests.len(), 1);
5701
5702 for request in requests {
5703 dispatcher.dispatch_and_complete(request).await;
5704 }
5705
5706 while runtime.has_pending() {
5707 runtime.tick().await.expect("tick");
5708 runtime.drain_microtasks().await.expect("microtasks");
5709 }
5710
5711 runtime
5712 .eval(
5713 r#"
5714 if (!globalThis.model) throw new Error("get_model not resolved");
5715 if (globalThis.model.provider !== "gemini") {
5716 throw new Error("Wrong provider: " + globalThis.model.provider);
5717 }
5718 if (globalThis.model.modelId !== "gemini-2.0-flash") {
5719 throw new Error("Wrong modelId: " + globalThis.model.modelId);
5720 }
5721 "#,
5722 )
5723 .await
5724 .expect("verify round trip");
5725 });
5726 }
5727
5728 #[test]
5729 fn dispatcher_session_set_thinking_level_resolves() {
5730 futures::executor::block_on(async {
5731 let runtime = Rc::new(
5732 PiJsRuntime::with_clock(DeterministicClock::new(0))
5733 .await
5734 .expect("runtime"),
5735 );
5736
5737 runtime
5738 .eval(
5739 r#"
5740 globalThis.setDone = false;
5741 pi.session("set_thinking_level", { level: "high" })
5742 .then(() => { globalThis.setDone = true; });
5743 "#,
5744 )
5745 .await
5746 .expect("eval");
5747
5748 let requests = runtime.drain_hostcall_requests();
5749 assert_eq!(requests.len(), 1);
5750
5751 let state = Arc::new(Mutex::new(serde_json::json!({})));
5752 let session = Arc::new(TestSession {
5753 state: Arc::clone(&state),
5754 messages: Arc::new(Mutex::new(Vec::new())),
5755 entries: Arc::new(Mutex::new(Vec::new())),
5756 branch: Arc::new(Mutex::new(Vec::new())),
5757 name: Arc::new(Mutex::new(None)),
5758 custom_entries: Arc::new(Mutex::new(Vec::new())),
5759 labels: Arc::new(Mutex::new(Vec::new())),
5760 });
5761
5762 let dispatcher = ExtensionDispatcher::new(
5763 Rc::clone(&runtime),
5764 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
5765 Arc::new(HttpConnector::with_defaults()),
5766 session,
5767 Arc::new(NullUiHandler),
5768 PathBuf::from("."),
5769 );
5770
5771 for request in requests {
5772 dispatcher.dispatch_and_complete(request).await;
5773 }
5774
5775 while runtime.has_pending() {
5776 runtime.tick().await.expect("tick");
5777 runtime.drain_microtasks().await.expect("microtasks");
5778 }
5779
5780 runtime
5782 .eval(
5783 r#"
5784 if (globalThis.setDone !== true) {
5785 throw new Error("set_thinking_level not resolved");
5786 }
5787 "#,
5788 )
5789 .await
5790 .expect("verify set_thinking_level");
5791
5792 let final_state = state
5793 .lock()
5794 .unwrap_or_else(std::sync::PoisonError::into_inner)
5795 .clone();
5796 assert_eq!(
5797 final_state.get("thinkingLevel").and_then(Value::as_str),
5798 Some("high")
5799 );
5800 });
5801 }
5802
5803 #[test]
5804 fn dispatcher_session_get_thinking_level_resolves() {
5805 futures::executor::block_on(async {
5806 let runtime = Rc::new(
5807 PiJsRuntime::with_clock(DeterministicClock::new(0))
5808 .await
5809 .expect("runtime"),
5810 );
5811
5812 runtime
5813 .eval(
5814 r#"
5815 globalThis.level = "__unset__";
5816 pi.session("get_thinking_level", {}).then((r) => { globalThis.level = r; });
5817 "#,
5818 )
5819 .await
5820 .expect("eval");
5821
5822 let requests = runtime.drain_hostcall_requests();
5823 assert_eq!(requests.len(), 1);
5824
5825 let state = Arc::new(Mutex::new(serde_json::json!({
5826 "thinkingLevel": "medium",
5827 })));
5828 let session = Arc::new(TestSession {
5829 state: Arc::clone(&state),
5830 messages: Arc::new(Mutex::new(Vec::new())),
5831 entries: Arc::new(Mutex::new(Vec::new())),
5832 branch: Arc::new(Mutex::new(Vec::new())),
5833 name: Arc::new(Mutex::new(None)),
5834 custom_entries: Arc::new(Mutex::new(Vec::new())),
5835 labels: Arc::new(Mutex::new(Vec::new())),
5836 });
5837
5838 let dispatcher = ExtensionDispatcher::new(
5839 Rc::clone(&runtime),
5840 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
5841 Arc::new(HttpConnector::with_defaults()),
5842 session,
5843 Arc::new(NullUiHandler),
5844 PathBuf::from("."),
5845 );
5846
5847 for request in requests {
5848 dispatcher.dispatch_and_complete(request).await;
5849 }
5850
5851 while runtime.has_pending() {
5852 runtime.tick().await.expect("tick");
5853 runtime.drain_microtasks().await.expect("microtasks");
5854 }
5855
5856 runtime
5857 .eval(
5858 r#"
5859 if (globalThis.level !== "medium") {
5860 throw new Error("Wrong thinking level: " + JSON.stringify(globalThis.level));
5861 }
5862 "#,
5863 )
5864 .await
5865 .expect("verify get_thinking_level");
5866 });
5867 }
5868
5869 #[test]
5870 fn dispatcher_session_get_thinking_level_null_when_unset() {
5871 futures::executor::block_on(async {
5872 let runtime = Rc::new(
5873 PiJsRuntime::with_clock(DeterministicClock::new(0))
5874 .await
5875 .expect("runtime"),
5876 );
5877
5878 runtime
5879 .eval(
5880 r#"
5881 globalThis.level = "__unset__";
5882 pi.session("get_thinking_level", {}).then((r) => { globalThis.level = r; });
5883 "#,
5884 )
5885 .await
5886 .expect("eval");
5887
5888 let requests = runtime.drain_hostcall_requests();
5889 assert_eq!(requests.len(), 1);
5890
5891 let dispatcher = build_dispatcher(Rc::clone(&runtime));
5892 for request in requests {
5893 dispatcher.dispatch_and_complete(request).await;
5894 }
5895
5896 while runtime.has_pending() {
5897 runtime.tick().await.expect("tick");
5898 runtime.drain_microtasks().await.expect("microtasks");
5899 }
5900
5901 runtime
5902 .eval(
5903 r#"
5904 if (globalThis.level !== null) {
5905 throw new Error("Unset thinking level should be null, got: " + JSON.stringify(globalThis.level));
5906 }
5907 "#,
5908 )
5909 .await
5910 .expect("verify null thinking level");
5911 });
5912 }
5913
5914 #[test]
5915 fn dispatcher_session_set_thinking_level_missing_level_rejects() {
5916 futures::executor::block_on(async {
5917 let runtime = Rc::new(
5918 PiJsRuntime::with_clock(DeterministicClock::new(0))
5919 .await
5920 .expect("runtime"),
5921 );
5922
5923 runtime
5924 .eval(
5925 r#"
5926 globalThis.err = null;
5927 pi.session("set_thinking_level", {})
5928 .catch((e) => { globalThis.err = e.code; });
5929 "#,
5930 )
5931 .await
5932 .expect("eval");
5933
5934 let requests = runtime.drain_hostcall_requests();
5935 assert_eq!(requests.len(), 1);
5936
5937 let dispatcher = build_dispatcher(Rc::clone(&runtime));
5938 for request in requests {
5939 dispatcher.dispatch_and_complete(request).await;
5940 }
5941
5942 while runtime.has_pending() {
5943 runtime.tick().await.expect("tick");
5944 runtime.drain_microtasks().await.expect("microtasks");
5945 }
5946
5947 runtime
5948 .eval(
5949 r#"
5950 if (globalThis.err !== "invalid_request") {
5951 throw new Error("Missing level should reject: " + globalThis.err);
5952 }
5953 "#,
5954 )
5955 .await
5956 .expect("verify validation error");
5957 });
5958 }
5959
5960 #[test]
5961 fn dispatcher_session_set_then_get_thinking_level_round_trip() {
5962 futures::executor::block_on(async {
5963 let runtime = Rc::new(
5964 PiJsRuntime::with_clock(DeterministicClock::new(0))
5965 .await
5966 .expect("runtime"),
5967 );
5968
5969 runtime
5971 .eval(
5972 r#"
5973 globalThis.setDone = false;
5974 pi.session("set_thinking_level", { level: "low" })
5975 .then(() => { globalThis.setDone = true; });
5976 "#,
5977 )
5978 .await
5979 .expect("eval set");
5980
5981 let requests = runtime.drain_hostcall_requests();
5982 assert_eq!(requests.len(), 1);
5983
5984 let state = Arc::new(Mutex::new(serde_json::json!({})));
5985 let session = Arc::new(TestSession {
5986 state: Arc::clone(&state),
5987 messages: Arc::new(Mutex::new(Vec::new())),
5988 entries: Arc::new(Mutex::new(Vec::new())),
5989 branch: Arc::new(Mutex::new(Vec::new())),
5990 name: Arc::new(Mutex::new(None)),
5991 custom_entries: Arc::new(Mutex::new(Vec::new())),
5992 labels: Arc::new(Mutex::new(Vec::new())),
5993 });
5994
5995 let dispatcher = ExtensionDispatcher::new(
5996 Rc::clone(&runtime),
5997 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
5998 Arc::new(HttpConnector::with_defaults()),
5999 session as Arc<dyn ExtensionSession + Send + Sync>,
6000 Arc::new(NullUiHandler),
6001 PathBuf::from("."),
6002 );
6003
6004 for request in requests {
6005 dispatcher.dispatch_and_complete(request).await;
6006 }
6007
6008 while runtime.has_pending() {
6009 runtime.tick().await.expect("tick");
6010 runtime.drain_microtasks().await.expect("microtasks");
6011 }
6012
6013 runtime
6015 .eval(
6016 r#"
6017 globalThis.level = "__unset__";
6018 pi.session("get_thinking_level", {}).then((r) => { globalThis.level = r; });
6019 "#,
6020 )
6021 .await
6022 .expect("eval get");
6023
6024 let requests = runtime.drain_hostcall_requests();
6025 assert_eq!(requests.len(), 1);
6026
6027 for request in requests {
6028 dispatcher.dispatch_and_complete(request).await;
6029 }
6030
6031 while runtime.has_pending() {
6032 runtime.tick().await.expect("tick");
6033 runtime.drain_microtasks().await.expect("microtasks");
6034 }
6035
6036 runtime
6037 .eval(
6038 r#"
6039 if (globalThis.level !== "low") {
6040 throw new Error("Round trip failed, got: " + JSON.stringify(globalThis.level));
6041 }
6042 "#,
6043 )
6044 .await
6045 .expect("verify round trip");
6046 });
6047 }
6048
6049 #[test]
6050 fn dispatcher_session_model_ops_accept_camel_case_aliases() {
6051 futures::executor::block_on(async {
6052 let runtime = Rc::new(
6053 PiJsRuntime::with_clock(DeterministicClock::new(0))
6054 .await
6055 .expect("runtime"),
6056 );
6057
6058 runtime
6059 .eval(
6060 r#"
6061 globalThis.setDone = false;
6062 globalThis.model = null;
6063 globalThis.thinkingSet = false;
6064 globalThis.thinking = "__unset__";
6065 pi.session("setmodel", { provider: "azure", modelId: "gpt-4" })
6066 .then(() => { globalThis.setDone = true; });
6067 pi.session("getmodel", {}).then((r) => { globalThis.model = r; });
6068 pi.session("setthinkinglevel", { level: "high" })
6069 .then(() => { globalThis.thinkingSet = true; });
6070 pi.session("getthinkinglevel", {}).then((r) => { globalThis.thinking = r; });
6071 "#,
6072 )
6073 .await
6074 .expect("eval");
6075
6076 let requests = runtime.drain_hostcall_requests();
6077 assert_eq!(requests.len(), 4);
6078
6079 let state = Arc::new(Mutex::new(serde_json::json!({})));
6080 let session = Arc::new(TestSession {
6081 state: Arc::clone(&state),
6082 messages: Arc::new(Mutex::new(Vec::new())),
6083 entries: Arc::new(Mutex::new(Vec::new())),
6084 branch: Arc::new(Mutex::new(Vec::new())),
6085 name: Arc::new(Mutex::new(None)),
6086 custom_entries: Arc::new(Mutex::new(Vec::new())),
6087 labels: Arc::new(Mutex::new(Vec::new())),
6088 });
6089
6090 let dispatcher = ExtensionDispatcher::new(
6091 Rc::clone(&runtime),
6092 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
6093 Arc::new(HttpConnector::with_defaults()),
6094 session as Arc<dyn ExtensionSession + Send + Sync>,
6095 Arc::new(NullUiHandler),
6096 PathBuf::from("."),
6097 );
6098
6099 for request in requests {
6100 dispatcher.dispatch_and_complete(request).await;
6101 }
6102
6103 while runtime.has_pending() {
6104 runtime.tick().await.expect("tick");
6105 runtime.drain_microtasks().await.expect("microtasks");
6106 }
6107
6108 runtime
6109 .eval(
6110 r#"
6111 if (!globalThis.setDone) throw new Error("setmodel not resolved");
6112 if (!globalThis.thinkingSet) throw new Error("setthinkinglevel not resolved");
6113 "#,
6114 )
6115 .await
6116 .expect("verify camelCase aliases");
6117 });
6118 }
6119
6120 #[test]
6121 fn dispatcher_session_set_model_accepts_model_id_snake_case() {
6122 futures::executor::block_on(async {
6123 let runtime = Rc::new(
6124 PiJsRuntime::with_clock(DeterministicClock::new(0))
6125 .await
6126 .expect("runtime"),
6127 );
6128
6129 runtime
6130 .eval(
6131 r#"
6132 globalThis.setDone = false;
6133 pi.session("set_model", { provider: "anthropic", model_id: "claude-opus-4-20250514" })
6134 .then(() => { globalThis.setDone = true; });
6135 "#,
6136 )
6137 .await
6138 .expect("eval");
6139
6140 let requests = runtime.drain_hostcall_requests();
6141 assert_eq!(requests.len(), 1);
6142
6143 let state = Arc::new(Mutex::new(serde_json::json!({})));
6144 let session = Arc::new(TestSession {
6145 state: Arc::clone(&state),
6146 messages: Arc::new(Mutex::new(Vec::new())),
6147 entries: Arc::new(Mutex::new(Vec::new())),
6148 branch: Arc::new(Mutex::new(Vec::new())),
6149 name: Arc::new(Mutex::new(None)),
6150 custom_entries: Arc::new(Mutex::new(Vec::new())),
6151 labels: Arc::new(Mutex::new(Vec::new())),
6152 });
6153
6154 let dispatcher = ExtensionDispatcher::new(
6155 Rc::clone(&runtime),
6156 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
6157 Arc::new(HttpConnector::with_defaults()),
6158 session,
6159 Arc::new(NullUiHandler),
6160 PathBuf::from("."),
6161 );
6162
6163 for request in requests {
6164 dispatcher.dispatch_and_complete(request).await;
6165 }
6166
6167 while runtime.has_pending() {
6168 runtime.tick().await.expect("tick");
6169 runtime.drain_microtasks().await.expect("microtasks");
6170 }
6171
6172 runtime
6173 .eval(
6174 r#"
6175 if (!globalThis.setDone) throw new Error("set_model with model_id not resolved");
6176 "#,
6177 )
6178 .await
6179 .expect("verify model_id snake_case");
6180
6181 let final_state = state
6182 .lock()
6183 .unwrap_or_else(std::sync::PoisonError::into_inner)
6184 .clone();
6185 assert_eq!(
6186 final_state.get("modelId").and_then(Value::as_str),
6187 Some("claude-opus-4-20250514")
6188 );
6189 });
6190 }
6191
6192 #[test]
6193 fn dispatcher_session_set_thinking_level_accepts_alt_keys() {
6194 futures::executor::block_on(async {
6195 let runtime = Rc::new(
6196 PiJsRuntime::with_clock(DeterministicClock::new(0))
6197 .await
6198 .expect("runtime"),
6199 );
6200
6201 runtime
6203 .eval(
6204 r#"
6205 globalThis.done1 = false;
6206 globalThis.done2 = false;
6207 pi.session("set_thinking_level", { thinkingLevel: "medium" })
6208 .then(() => { globalThis.done1 = true; });
6209 pi.session("set_thinking_level", { thinking_level: "low" })
6210 .then(() => { globalThis.done2 = true; });
6211 "#,
6212 )
6213 .await
6214 .expect("eval");
6215
6216 let requests = runtime.drain_hostcall_requests();
6217 assert_eq!(requests.len(), 2);
6218
6219 let state = Arc::new(Mutex::new(serde_json::json!({})));
6220 let session = Arc::new(TestSession {
6221 state: Arc::clone(&state),
6222 messages: Arc::new(Mutex::new(Vec::new())),
6223 entries: Arc::new(Mutex::new(Vec::new())),
6224 branch: Arc::new(Mutex::new(Vec::new())),
6225 name: Arc::new(Mutex::new(None)),
6226 custom_entries: Arc::new(Mutex::new(Vec::new())),
6227 labels: Arc::new(Mutex::new(Vec::new())),
6228 });
6229
6230 let dispatcher = ExtensionDispatcher::new(
6231 Rc::clone(&runtime),
6232 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
6233 Arc::new(HttpConnector::with_defaults()),
6234 session,
6235 Arc::new(NullUiHandler),
6236 PathBuf::from("."),
6237 );
6238
6239 for request in requests {
6240 dispatcher.dispatch_and_complete(request).await;
6241 }
6242
6243 while runtime.has_pending() {
6244 runtime.tick().await.expect("tick");
6245 runtime.drain_microtasks().await.expect("microtasks");
6246 }
6247
6248 runtime
6249 .eval(
6250 r#"
6251 if (!globalThis.done1) throw new Error("thinkingLevel key not resolved");
6252 if (!globalThis.done2) throw new Error("thinking_level key not resolved");
6253 "#,
6254 )
6255 .await
6256 .expect("verify alt keys");
6257
6258 let final_state = state
6260 .lock()
6261 .unwrap_or_else(std::sync::PoisonError::into_inner)
6262 .clone();
6263 assert_eq!(
6264 final_state.get("thinkingLevel").and_then(Value::as_str),
6265 Some("low")
6266 );
6267 });
6268 }
6269
6270 #[test]
6271 fn dispatcher_session_get_model_null_when_unset() {
6272 futures::executor::block_on(async {
6273 let runtime = Rc::new(
6274 PiJsRuntime::with_clock(DeterministicClock::new(0))
6275 .await
6276 .expect("runtime"),
6277 );
6278
6279 runtime
6280 .eval(
6281 r#"
6282 globalThis.model = "__unset__";
6283 pi.session("get_model", {}).then((r) => { globalThis.model = r; });
6284 "#,
6285 )
6286 .await
6287 .expect("eval");
6288
6289 let requests = runtime.drain_hostcall_requests();
6290 assert_eq!(requests.len(), 1);
6291
6292 let dispatcher = build_dispatcher(Rc::clone(&runtime));
6294 for request in requests {
6295 dispatcher.dispatch_and_complete(request).await;
6296 }
6297
6298 while runtime.has_pending() {
6299 runtime.tick().await.expect("tick");
6300 runtime.drain_microtasks().await.expect("microtasks");
6301 }
6302
6303 runtime
6304 .eval(
6305 r#"
6306 if (!globalThis.model) throw new Error("get_model not resolved");
6307 if (globalThis.model.provider !== null) {
6308 throw new Error("Unset provider should be null, got: " + JSON.stringify(globalThis.model.provider));
6309 }
6310 if (globalThis.model.modelId !== null) {
6311 throw new Error("Unset modelId should be null, got: " + JSON.stringify(globalThis.model.modelId));
6312 }
6313 "#,
6314 )
6315 .await
6316 .expect("verify null model");
6317 });
6318 }
6319
6320 #[test]
6323 fn dispatcher_session_set_label_resolves_and_persists() {
6324 futures::executor::block_on(async {
6325 let runtime = Rc::new(
6326 PiJsRuntime::with_clock(DeterministicClock::new(0))
6327 .await
6328 .expect("runtime"),
6329 );
6330
6331 runtime
6332 .eval(
6333 r#"
6334 globalThis.result = "__unset__";
6335 pi.session("set_label", { targetId: "msg-42", label: "important" })
6336 .then((r) => { globalThis.result = r; });
6337 "#,
6338 )
6339 .await
6340 .expect("eval");
6341
6342 let requests = runtime.drain_hostcall_requests();
6343 assert_eq!(requests.len(), 1);
6344
6345 let labels: Arc<Mutex<Vec<LabelEntry>>> = Arc::new(Mutex::new(Vec::new()));
6346 let session = Arc::new(TestSession {
6347 state: Arc::new(Mutex::new(serde_json::json!({}))),
6348 messages: Arc::new(Mutex::new(Vec::new())),
6349 entries: Arc::new(Mutex::new(Vec::new())),
6350 branch: Arc::new(Mutex::new(Vec::new())),
6351 name: Arc::new(Mutex::new(None)),
6352 custom_entries: Arc::new(Mutex::new(Vec::new())),
6353 labels: Arc::clone(&labels),
6354 });
6355
6356 let dispatcher = ExtensionDispatcher::new(
6357 Rc::clone(&runtime),
6358 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
6359 Arc::new(HttpConnector::with_defaults()),
6360 session,
6361 Arc::new(NullUiHandler),
6362 PathBuf::from("."),
6363 );
6364
6365 for request in requests {
6366 dispatcher.dispatch_and_complete(request).await;
6367 }
6368
6369 while runtime.has_pending() {
6370 runtime.tick().await.expect("tick");
6371 runtime.drain_microtasks().await.expect("microtasks");
6372 }
6373
6374 let captured = labels
6376 .lock()
6377 .unwrap_or_else(std::sync::PoisonError::into_inner);
6378 assert_eq!(captured.len(), 1);
6379 assert_eq!(captured[0].0, "msg-42");
6380 assert_eq!(captured[0].1.as_deref(), Some("important"));
6381 drop(captured);
6382 });
6383 }
6384
6385 #[test]
6386 fn dispatcher_session_set_label_remove_label_with_null() {
6387 futures::executor::block_on(async {
6388 let runtime = Rc::new(
6389 PiJsRuntime::with_clock(DeterministicClock::new(0))
6390 .await
6391 .expect("runtime"),
6392 );
6393
6394 runtime
6395 .eval(
6396 r#"
6397 globalThis.result = "__unset__";
6398 pi.session("set_label", { targetId: "msg-99" })
6399 .then((r) => { globalThis.result = r; });
6400 "#,
6401 )
6402 .await
6403 .expect("eval");
6404
6405 let requests = runtime.drain_hostcall_requests();
6406 assert_eq!(requests.len(), 1);
6407
6408 let labels: Arc<Mutex<Vec<LabelEntry>>> = Arc::new(Mutex::new(Vec::new()));
6409 let session = Arc::new(TestSession {
6410 state: Arc::new(Mutex::new(serde_json::json!({}))),
6411 messages: Arc::new(Mutex::new(Vec::new())),
6412 entries: Arc::new(Mutex::new(Vec::new())),
6413 branch: Arc::new(Mutex::new(Vec::new())),
6414 name: Arc::new(Mutex::new(None)),
6415 custom_entries: Arc::new(Mutex::new(Vec::new())),
6416 labels: Arc::clone(&labels),
6417 });
6418
6419 let dispatcher = ExtensionDispatcher::new(
6420 Rc::clone(&runtime),
6421 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
6422 Arc::new(HttpConnector::with_defaults()),
6423 session,
6424 Arc::new(NullUiHandler),
6425 PathBuf::from("."),
6426 );
6427
6428 for request in requests {
6429 dispatcher.dispatch_and_complete(request).await;
6430 }
6431
6432 while runtime.has_pending() {
6433 runtime.tick().await.expect("tick");
6434 runtime.drain_microtasks().await.expect("microtasks");
6435 }
6436
6437 let captured = labels
6439 .lock()
6440 .unwrap_or_else(std::sync::PoisonError::into_inner);
6441 assert_eq!(captured.len(), 1);
6442 assert_eq!(captured[0].0, "msg-99");
6443 assert!(captured[0].1.is_none());
6444 drop(captured);
6445 });
6446 }
6447
6448 #[test]
6449 fn dispatcher_session_set_label_missing_target_id_rejects() {
6450 futures::executor::block_on(async {
6451 let runtime = Rc::new(
6452 PiJsRuntime::with_clock(DeterministicClock::new(0))
6453 .await
6454 .expect("runtime"),
6455 );
6456
6457 runtime
6458 .eval(
6459 r#"
6460 globalThis.errMsg = "";
6461 pi.session("set_label", { label: "orphaned" })
6462 .then(() => { globalThis.errMsg = "should_not_resolve"; })
6463 .catch((e) => { globalThis.errMsg = e.message || String(e); });
6464 "#,
6465 )
6466 .await
6467 .expect("eval");
6468
6469 let requests = runtime.drain_hostcall_requests();
6470 assert_eq!(requests.len(), 1);
6471
6472 let dispatcher = build_dispatcher(Rc::clone(&runtime));
6473 for request in requests {
6474 dispatcher.dispatch_and_complete(request).await;
6475 }
6476
6477 while runtime.has_pending() {
6478 runtime.tick().await.expect("tick");
6479 runtime.drain_microtasks().await.expect("microtasks");
6480 }
6481
6482 runtime
6483 .eval(
6484 r#"
6485 if (!globalThis.errMsg || globalThis.errMsg === "should_not_resolve") {
6486 throw new Error("Expected rejection, got: " + globalThis.errMsg);
6487 }
6488 if (!globalThis.errMsg.includes("targetId")) {
6489 throw new Error("Expected error about targetId, got: " + globalThis.errMsg);
6490 }
6491 "#,
6492 )
6493 .await
6494 .expect("verify rejection");
6495 });
6496 }
6497
6498 #[test]
6499 fn dispatcher_session_set_label_accepts_snake_case_target_id() {
6500 futures::executor::block_on(async {
6501 let runtime = Rc::new(
6502 PiJsRuntime::with_clock(DeterministicClock::new(0))
6503 .await
6504 .expect("runtime"),
6505 );
6506
6507 runtime
6508 .eval(
6509 r#"
6510 globalThis.result = "__unset__";
6511 pi.session("set_label", { target_id: "msg-77", label: "reviewed" })
6512 .then((r) => { globalThis.result = r; });
6513 "#,
6514 )
6515 .await
6516 .expect("eval");
6517
6518 let requests = runtime.drain_hostcall_requests();
6519 assert_eq!(requests.len(), 1);
6520
6521 let labels: Arc<Mutex<Vec<LabelEntry>>> = Arc::new(Mutex::new(Vec::new()));
6522 let session = Arc::new(TestSession {
6523 state: Arc::new(Mutex::new(serde_json::json!({}))),
6524 messages: Arc::new(Mutex::new(Vec::new())),
6525 entries: Arc::new(Mutex::new(Vec::new())),
6526 branch: Arc::new(Mutex::new(Vec::new())),
6527 name: Arc::new(Mutex::new(None)),
6528 custom_entries: Arc::new(Mutex::new(Vec::new())),
6529 labels: Arc::clone(&labels),
6530 });
6531
6532 let dispatcher = ExtensionDispatcher::new(
6533 Rc::clone(&runtime),
6534 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
6535 Arc::new(HttpConnector::with_defaults()),
6536 session,
6537 Arc::new(NullUiHandler),
6538 PathBuf::from("."),
6539 );
6540
6541 for request in requests {
6542 dispatcher.dispatch_and_complete(request).await;
6543 }
6544
6545 while runtime.has_pending() {
6546 runtime.tick().await.expect("tick");
6547 runtime.drain_microtasks().await.expect("microtasks");
6548 }
6549
6550 let captured = labels
6551 .lock()
6552 .unwrap_or_else(std::sync::PoisonError::into_inner);
6553 assert_eq!(captured.len(), 1);
6554 assert_eq!(captured[0].0, "msg-77");
6555 assert_eq!(captured[0].1.as_deref(), Some("reviewed"));
6556 drop(captured);
6557 });
6558 }
6559
6560 #[test]
6561 fn dispatcher_session_set_label_camel_case_op_alias() {
6562 futures::executor::block_on(async {
6563 let runtime = Rc::new(
6564 PiJsRuntime::with_clock(DeterministicClock::new(0))
6565 .await
6566 .expect("runtime"),
6567 );
6568
6569 runtime
6571 .eval(
6572 r#"
6573 globalThis.result = "__unset__";
6574 pi.session("setLabel", { targetId: "entry-5", label: "flagged" })
6575 .then((r) => { globalThis.result = r; });
6576 "#,
6577 )
6578 .await
6579 .expect("eval");
6580
6581 let requests = runtime.drain_hostcall_requests();
6582 assert_eq!(requests.len(), 1);
6583
6584 let labels: Arc<Mutex<Vec<LabelEntry>>> = Arc::new(Mutex::new(Vec::new()));
6585 let session = Arc::new(TestSession {
6586 state: Arc::new(Mutex::new(serde_json::json!({}))),
6587 messages: Arc::new(Mutex::new(Vec::new())),
6588 entries: Arc::new(Mutex::new(Vec::new())),
6589 branch: Arc::new(Mutex::new(Vec::new())),
6590 name: Arc::new(Mutex::new(None)),
6591 custom_entries: Arc::new(Mutex::new(Vec::new())),
6592 labels: Arc::clone(&labels),
6593 });
6594
6595 let dispatcher = ExtensionDispatcher::new(
6596 Rc::clone(&runtime),
6597 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
6598 Arc::new(HttpConnector::with_defaults()),
6599 session,
6600 Arc::new(NullUiHandler),
6601 PathBuf::from("."),
6602 );
6603
6604 for request in requests {
6605 dispatcher.dispatch_and_complete(request).await;
6606 }
6607
6608 while runtime.has_pending() {
6609 runtime.tick().await.expect("tick");
6610 runtime.drain_microtasks().await.expect("microtasks");
6611 }
6612
6613 let captured = labels
6614 .lock()
6615 .unwrap_or_else(std::sync::PoisonError::into_inner);
6616 assert_eq!(captured.len(), 1);
6617 assert_eq!(captured[0].0, "entry-5");
6618 assert_eq!(captured[0].1.as_deref(), Some("flagged"));
6619 drop(captured);
6620 });
6621 }
6622
6623 #[test]
6626 fn dispatcher_tool_write_creates_file_and_resolves() {
6627 futures::executor::block_on(async {
6628 let temp_dir = tempfile::tempdir().expect("tempdir");
6629
6630 let runtime = Rc::new(
6631 PiJsRuntime::with_clock(DeterministicClock::new(0))
6632 .await
6633 .expect("runtime"),
6634 );
6635
6636 let file_path = temp_dir.path().join("output.txt");
6637 let file_path_str = file_path.display().to_string().replace('\\', "\\\\");
6638 let script = format!(
6639 r#"
6640 globalThis.result = null;
6641 pi.tool("write", {{ path: "{file_path_str}", content: "written by extension" }})
6642 .then((r) => {{ globalThis.result = r; }});
6643 "#
6644 );
6645 runtime.eval(&script).await.expect("eval");
6646
6647 let requests = runtime.drain_hostcall_requests();
6648 assert_eq!(requests.len(), 1);
6649
6650 let dispatcher = ExtensionDispatcher::new(
6651 Rc::clone(&runtime),
6652 Arc::new(ToolRegistry::new(&["write"], temp_dir.path(), None)),
6653 Arc::new(HttpConnector::with_defaults()),
6654 Arc::new(NullSession),
6655 Arc::new(NullUiHandler),
6656 temp_dir.path().to_path_buf(),
6657 );
6658
6659 for request in requests {
6660 dispatcher.dispatch_and_complete(request).await;
6661 }
6662
6663 while runtime.has_pending() {
6664 runtime.tick().await.expect("tick");
6665 runtime.drain_microtasks().await.expect("microtasks");
6666 }
6667
6668 assert!(file_path.exists());
6670 let content = std::fs::read_to_string(&file_path).expect("read file");
6671 assert_eq!(content, "written by extension");
6672 });
6673 }
6674
6675 #[test]
6676 fn dispatcher_tool_ls_lists_directory() {
6677 futures::executor::block_on(async {
6678 let temp_dir = tempfile::tempdir().expect("tempdir");
6679 std::fs::write(temp_dir.path().join("alpha.txt"), "a").expect("write");
6680 std::fs::write(temp_dir.path().join("beta.txt"), "b").expect("write");
6681
6682 let runtime = Rc::new(
6683 PiJsRuntime::with_clock(DeterministicClock::new(0))
6684 .await
6685 .expect("runtime"),
6686 );
6687
6688 runtime
6689 .eval(
6690 r#"
6691 globalThis.result = null;
6692 pi.tool("ls", { path: "." })
6693 .then((r) => { globalThis.result = r; });
6694 "#,
6695 )
6696 .await
6697 .expect("eval");
6698
6699 let requests = runtime.drain_hostcall_requests();
6700 assert_eq!(requests.len(), 1);
6701
6702 let dispatcher = ExtensionDispatcher::new(
6703 Rc::clone(&runtime),
6704 Arc::new(ToolRegistry::new(&["ls"], temp_dir.path(), None)),
6705 Arc::new(HttpConnector::with_defaults()),
6706 Arc::new(NullSession),
6707 Arc::new(NullUiHandler),
6708 temp_dir.path().to_path_buf(),
6709 );
6710
6711 for request in requests {
6712 dispatcher.dispatch_and_complete(request).await;
6713 }
6714
6715 while runtime.has_pending() {
6716 runtime.tick().await.expect("tick");
6717 runtime.drain_microtasks().await.expect("microtasks");
6718 }
6719
6720 runtime
6721 .eval(
6722 r#"
6723 if (globalThis.result === null) throw new Error("ls not resolved");
6724 let s = JSON.stringify(globalThis.result);
6725 if (!s.includes("alpha.txt") || !s.includes("beta.txt")) {
6726 throw new Error("Missing files in ls output: " + s);
6727 }
6728 "#,
6729 )
6730 .await
6731 .expect("verify ls result");
6732 });
6733 }
6734
6735 #[test]
6736 fn dispatcher_tool_grep_searches_content() {
6737 futures::executor::block_on(async {
6738 let temp_dir = tempfile::tempdir().expect("tempdir");
6739 std::fs::write(
6740 temp_dir.path().join("data.txt"),
6741 "line one\nline two\nline three",
6742 )
6743 .expect("write");
6744
6745 let runtime = Rc::new(
6746 PiJsRuntime::with_clock(DeterministicClock::new(0))
6747 .await
6748 .expect("runtime"),
6749 );
6750
6751 let dir = temp_dir.path().display().to_string().replace('\\', "\\\\");
6752 let script = format!(
6753 r#"
6754 globalThis.result = null;
6755 pi.tool("grep", {{ pattern: "two", path: "{dir}" }})
6756 .then((r) => {{ globalThis.result = r; }});
6757 "#
6758 );
6759 runtime.eval(&script).await.expect("eval");
6760
6761 let requests = runtime.drain_hostcall_requests();
6762 assert_eq!(requests.len(), 1);
6763
6764 let dispatcher = ExtensionDispatcher::new(
6765 Rc::clone(&runtime),
6766 Arc::new(ToolRegistry::new(&["grep"], temp_dir.path(), None)),
6767 Arc::new(HttpConnector::with_defaults()),
6768 Arc::new(NullSession),
6769 Arc::new(NullUiHandler),
6770 temp_dir.path().to_path_buf(),
6771 );
6772
6773 for request in requests {
6774 dispatcher.dispatch_and_complete(request).await;
6775 }
6776
6777 while runtime.has_pending() {
6778 runtime.tick().await.expect("tick");
6779 runtime.drain_microtasks().await.expect("microtasks");
6780 }
6781
6782 runtime
6783 .eval(
6784 r#"
6785 if (globalThis.result === null) throw new Error("grep not resolved");
6786 let s = JSON.stringify(globalThis.result);
6787 if (!s.includes("two")) {
6788 throw new Error("grep should find 'two': " + s);
6789 }
6790 "#,
6791 )
6792 .await
6793 .expect("verify grep result");
6794 });
6795 }
6796
6797 #[test]
6798 fn dispatcher_tool_edit_modifies_file_content() {
6799 futures::executor::block_on(async {
6800 let temp_dir = tempfile::tempdir().expect("tempdir");
6801 std::fs::write(temp_dir.path().join("target.txt"), "old text here").expect("write");
6802
6803 let runtime = Rc::new(
6804 PiJsRuntime::with_clock(DeterministicClock::new(0))
6805 .await
6806 .expect("runtime"),
6807 );
6808
6809 runtime
6810 .eval(
6811 r#"
6812 globalThis.result = null;
6813 pi.tool("edit", { path: "target.txt", oldText: "old text", newText: "new text" })
6814 .then((r) => { globalThis.result = r; });
6815 "#,
6816 )
6817 .await
6818 .expect("eval");
6819
6820 let requests = runtime.drain_hostcall_requests();
6821 assert_eq!(requests.len(), 1);
6822
6823 let dispatcher = ExtensionDispatcher::new(
6824 Rc::clone(&runtime),
6825 Arc::new(ToolRegistry::new(&["edit"], temp_dir.path(), None)),
6826 Arc::new(HttpConnector::with_defaults()),
6827 Arc::new(NullSession),
6828 Arc::new(NullUiHandler),
6829 temp_dir.path().to_path_buf(),
6830 );
6831
6832 for request in requests {
6833 dispatcher.dispatch_and_complete(request).await;
6834 }
6835
6836 while runtime.has_pending() {
6837 runtime.tick().await.expect("tick");
6838 runtime.drain_microtasks().await.expect("microtasks");
6839 }
6840
6841 let content =
6842 std::fs::read_to_string(temp_dir.path().join("target.txt")).expect("read file");
6843 assert!(
6844 content.contains("new text"),
6845 "Expected edited content, got: {content}"
6846 );
6847 });
6848 }
6849
6850 #[test]
6851 fn dispatcher_tool_find_discovers_files() {
6852 futures::executor::block_on(async {
6853 let temp_dir = tempfile::tempdir().expect("tempdir");
6854 std::fs::write(temp_dir.path().join("code.rs"), "fn main(){}").expect("write");
6855 std::fs::write(temp_dir.path().join("data.json"), "{}").expect("write");
6856
6857 let runtime = Rc::new(
6858 PiJsRuntime::with_clock(DeterministicClock::new(0))
6859 .await
6860 .expect("runtime"),
6861 );
6862
6863 runtime
6864 .eval(
6865 r#"
6866 globalThis.result = null;
6867 pi.tool("find", { pattern: "*.rs" })
6868 .then((r) => { globalThis.result = r; });
6869 "#,
6870 )
6871 .await
6872 .expect("eval");
6873
6874 let requests = runtime.drain_hostcall_requests();
6875 assert_eq!(requests.len(), 1);
6876
6877 let dispatcher = ExtensionDispatcher::new(
6878 Rc::clone(&runtime),
6879 Arc::new(ToolRegistry::new(&["find"], temp_dir.path(), None)),
6880 Arc::new(HttpConnector::with_defaults()),
6881 Arc::new(NullSession),
6882 Arc::new(NullUiHandler),
6883 temp_dir.path().to_path_buf(),
6884 );
6885
6886 for request in requests {
6887 dispatcher.dispatch_and_complete(request).await;
6888 }
6889
6890 while runtime.has_pending() {
6891 runtime.tick().await.expect("tick");
6892 runtime.drain_microtasks().await.expect("microtasks");
6893 }
6894
6895 runtime
6896 .eval(
6897 r#"
6898 if (globalThis.result === null) throw new Error("find not resolved");
6899 let s = JSON.stringify(globalThis.result);
6900 if (!s.includes("code.rs")) {
6901 throw new Error("find should discover code.rs: " + s);
6902 }
6903 if (s.includes("data.json")) {
6904 throw new Error("find *.rs should not include data.json: " + s);
6905 }
6906 "#,
6907 )
6908 .await
6909 .expect("verify find result");
6910 });
6911 }
6912
6913 #[test]
6914 fn dispatcher_tool_multiple_tools_sequentially() {
6915 futures::executor::block_on(async {
6916 let temp_dir = tempfile::tempdir().expect("tempdir");
6917 std::fs::write(temp_dir.path().join("file.txt"), "hello").expect("write");
6918
6919 let runtime = Rc::new(
6920 PiJsRuntime::with_clock(DeterministicClock::new(0))
6921 .await
6922 .expect("runtime"),
6923 );
6924
6925 runtime
6927 .eval(
6928 r#"
6929 globalThis.readResult = null;
6930 globalThis.lsResult = null;
6931 pi.tool("read", { path: "file.txt" })
6932 .then((r) => { globalThis.readResult = r; });
6933 pi.tool("ls", { path: "." })
6934 .then((r) => { globalThis.lsResult = r; });
6935 "#,
6936 )
6937 .await
6938 .expect("eval");
6939
6940 let requests = runtime.drain_hostcall_requests();
6941 assert_eq!(requests.len(), 2);
6942
6943 let dispatcher = ExtensionDispatcher::new(
6944 Rc::clone(&runtime),
6945 Arc::new(ToolRegistry::new(&["read", "ls"], temp_dir.path(), None)),
6946 Arc::new(HttpConnector::with_defaults()),
6947 Arc::new(NullSession),
6948 Arc::new(NullUiHandler),
6949 temp_dir.path().to_path_buf(),
6950 );
6951
6952 for request in requests {
6953 dispatcher.dispatch_and_complete(request).await;
6954 }
6955
6956 while runtime.has_pending() {
6957 runtime.tick().await.expect("tick");
6958 runtime.drain_microtasks().await.expect("microtasks");
6959 }
6960
6961 runtime
6962 .eval(
6963 r#"
6964 if (globalThis.readResult === null) throw new Error("read not resolved");
6965 if (globalThis.lsResult === null) throw new Error("ls not resolved");
6966 "#,
6967 )
6968 .await
6969 .expect("verify both tools resolved");
6970 });
6971 }
6972
6973 #[test]
6974 fn dispatcher_tool_error_propagates_to_js() {
6975 futures::executor::block_on(async {
6976 let temp_dir = tempfile::tempdir().expect("tempdir");
6977
6978 let runtime = Rc::new(
6979 PiJsRuntime::with_clock(DeterministicClock::new(0))
6980 .await
6981 .expect("runtime"),
6982 );
6983
6984 runtime
6986 .eval(
6987 r#"
6988 globalThis.errMsg = "";
6989 pi.tool("read", { path: "nonexistent_file.txt" })
6990 .then(() => { globalThis.errMsg = "should_not_resolve"; })
6991 .catch((e) => { globalThis.errMsg = e.message || String(e); });
6992 "#,
6993 )
6994 .await
6995 .expect("eval");
6996
6997 let requests = runtime.drain_hostcall_requests();
6998 assert_eq!(requests.len(), 1);
6999
7000 let dispatcher = ExtensionDispatcher::new(
7001 Rc::clone(&runtime),
7002 Arc::new(ToolRegistry::new(&["read"], temp_dir.path(), None)),
7003 Arc::new(HttpConnector::with_defaults()),
7004 Arc::new(NullSession),
7005 Arc::new(NullUiHandler),
7006 temp_dir.path().to_path_buf(),
7007 );
7008
7009 for request in requests {
7010 dispatcher.dispatch_and_complete(request).await;
7011 }
7012
7013 while runtime.has_pending() {
7014 runtime.tick().await.expect("tick");
7015 runtime.drain_microtasks().await.expect("microtasks");
7016 }
7017
7018 runtime
7021 .eval(
7022 r#"
7023 // Just verify something happened - error propagation is tool-specific
7024 if (globalThis.errMsg === "" && globalThis.result === null) {
7025 throw new Error("Neither resolved nor rejected");
7026 }
7027 "#,
7028 )
7029 .await
7030 .expect("verify tool error handling");
7031 });
7032 }
7033
7034 fn spawn_http_server_with_status(status: u16, body: &'static str) -> std::net::SocketAddr {
7037 let listener = TcpListener::bind("127.0.0.1:0").expect("bind http server");
7038 let addr = listener.local_addr().expect("server addr");
7039 thread::spawn(move || {
7040 if let Ok((mut stream, _)) = listener.accept() {
7041 let mut buf = [0u8; 1024];
7042 let _ = stream.read(&mut buf);
7043 let response = format!(
7044 "HTTP/1.1 {status} Error\r\nContent-Length: {len}\r\nContent-Type: text/plain\r\n\r\n{body}",
7045 status = status,
7046 len = body.len(),
7047 body = body,
7048 );
7049 let _ = stream.write_all(response.as_bytes());
7050 }
7051 });
7052 addr
7053 }
7054
7055 #[test]
7056 #[cfg(unix)] fn dispatcher_http_post_sends_body() {
7058 futures::executor::block_on(async {
7059 let addr = spawn_http_server("post-ok");
7060 let url = format!("http://{addr}/data");
7061
7062 let runtime = Rc::new(
7063 PiJsRuntime::with_clock(DeterministicClock::new(0))
7064 .await
7065 .expect("runtime"),
7066 );
7067
7068 let script = format!(
7069 r#"
7070 globalThis.result = null;
7071 pi.http({{ url: "{url}", method: "POST", body: "test-payload" }})
7072 .then((r) => {{ globalThis.result = r; }});
7073 "#
7074 );
7075 runtime.eval(&script).await.expect("eval");
7076
7077 let requests = runtime.drain_hostcall_requests();
7078 assert_eq!(requests.len(), 1);
7079
7080 let http_connector = HttpConnector::new(HttpConnectorConfig {
7081 require_tls: false,
7082 ..Default::default()
7083 });
7084 let dispatcher = ExtensionDispatcher::new(
7085 Rc::clone(&runtime),
7086 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
7087 Arc::new(http_connector),
7088 Arc::new(NullSession),
7089 Arc::new(NullUiHandler),
7090 PathBuf::from("."),
7091 );
7092
7093 for request in requests {
7094 dispatcher.dispatch_and_complete(request).await;
7095 }
7096
7097 while runtime.has_pending() {
7098 runtime.tick().await.expect("tick");
7099 runtime.drain_microtasks().await.expect("microtasks");
7100 }
7101
7102 runtime
7103 .eval(
7104 r#"
7105 if (globalThis.result === null) throw new Error("POST not resolved");
7106 if (globalThis.result.status !== 200) {
7107 throw new Error("Expected 200, got: " + globalThis.result.status);
7108 }
7109 "#,
7110 )
7111 .await
7112 .expect("verify POST result");
7113 });
7114 }
7115
7116 #[test]
7117 fn dispatcher_http_missing_url_rejects() {
7118 futures::executor::block_on(async {
7119 let runtime = Rc::new(
7120 PiJsRuntime::with_clock(DeterministicClock::new(0))
7121 .await
7122 .expect("runtime"),
7123 );
7124
7125 runtime
7126 .eval(
7127 r#"
7128 globalThis.errMsg = "";
7129 pi.http({ method: "GET" })
7130 .then(() => { globalThis.errMsg = "should_not_resolve"; })
7131 .catch((e) => { globalThis.errMsg = e.message || String(e); });
7132 "#,
7133 )
7134 .await
7135 .expect("eval");
7136
7137 let requests = runtime.drain_hostcall_requests();
7138 assert_eq!(requests.len(), 1);
7139
7140 let http_connector = HttpConnector::new(HttpConnectorConfig {
7141 require_tls: false,
7142 ..Default::default()
7143 });
7144 let dispatcher = ExtensionDispatcher::new(
7145 Rc::clone(&runtime),
7146 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
7147 Arc::new(http_connector),
7148 Arc::new(NullSession),
7149 Arc::new(NullUiHandler),
7150 PathBuf::from("."),
7151 );
7152
7153 for request in requests {
7154 dispatcher.dispatch_and_complete(request).await;
7155 }
7156
7157 while runtime.has_pending() {
7158 runtime.tick().await.expect("tick");
7159 runtime.drain_microtasks().await.expect("microtasks");
7160 }
7161
7162 runtime
7163 .eval(
7164 r#"
7165 if (globalThis.errMsg === "should_not_resolve") {
7166 throw new Error("Expected rejection for missing URL");
7167 }
7168 "#,
7169 )
7170 .await
7171 .expect("verify missing URL rejection");
7172 });
7173 }
7174
7175 #[test]
7176 fn dispatcher_http_custom_headers() {
7177 futures::executor::block_on(async {
7178 let addr = spawn_http_server("headers-ok");
7179 let url = format!("http://{addr}/headers");
7180
7181 let runtime = Rc::new(
7182 PiJsRuntime::with_clock(DeterministicClock::new(0))
7183 .await
7184 .expect("runtime"),
7185 );
7186
7187 let script = format!(
7188 r#"
7189 globalThis.result = null;
7190 pi.http({{
7191 url: "{url}",
7192 method: "GET",
7193 headers: {{ "X-Custom": "test-value", "Accept": "application/json" }}
7194 }}).then((r) => {{ globalThis.result = r; }});
7195 "#
7196 );
7197 runtime.eval(&script).await.expect("eval");
7198
7199 let requests = runtime.drain_hostcall_requests();
7200 assert_eq!(requests.len(), 1);
7201
7202 let http_connector = HttpConnector::new(HttpConnectorConfig {
7203 require_tls: false,
7204 ..Default::default()
7205 });
7206 let dispatcher = ExtensionDispatcher::new(
7207 Rc::clone(&runtime),
7208 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
7209 Arc::new(http_connector),
7210 Arc::new(NullSession),
7211 Arc::new(NullUiHandler),
7212 PathBuf::from("."),
7213 );
7214
7215 for request in requests {
7216 dispatcher.dispatch_and_complete(request).await;
7217 }
7218
7219 while runtime.has_pending() {
7220 runtime.tick().await.expect("tick");
7221 runtime.drain_microtasks().await.expect("microtasks");
7222 }
7223
7224 runtime
7225 .eval(
7226 r#"
7227 if (globalThis.result === null) throw new Error("HTTP not resolved");
7228 if (globalThis.result.status !== 200) {
7229 throw new Error("Expected 200, got: " + globalThis.result.status);
7230 }
7231 "#,
7232 )
7233 .await
7234 .expect("verify headers request");
7235 });
7236 }
7237
7238 #[test]
7239 fn dispatcher_http_connection_refused_rejects() {
7240 futures::executor::block_on(async {
7241 let runtime = Rc::new(
7242 PiJsRuntime::with_clock(DeterministicClock::new(0))
7243 .await
7244 .expect("runtime"),
7245 );
7246
7247 runtime
7249 .eval(
7250 r#"
7251 globalThis.errMsg = "";
7252 pi.http({ url: "http://127.0.0.1:1/never", method: "GET" })
7253 .then(() => { globalThis.errMsg = "should_not_resolve"; })
7254 .catch((e) => { globalThis.errMsg = e.message || String(e); });
7255 "#,
7256 )
7257 .await
7258 .expect("eval");
7259
7260 let requests = runtime.drain_hostcall_requests();
7261 assert_eq!(requests.len(), 1);
7262
7263 let http_connector = HttpConnector::new(HttpConnectorConfig {
7264 require_tls: false,
7265 ..Default::default()
7266 });
7267 let dispatcher = ExtensionDispatcher::new(
7268 Rc::clone(&runtime),
7269 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
7270 Arc::new(http_connector),
7271 Arc::new(NullSession),
7272 Arc::new(NullUiHandler),
7273 PathBuf::from("."),
7274 );
7275
7276 for request in requests {
7277 dispatcher.dispatch_and_complete(request).await;
7278 }
7279
7280 while runtime.has_pending() {
7281 runtime.tick().await.expect("tick");
7282 runtime.drain_microtasks().await.expect("microtasks");
7283 }
7284
7285 runtime
7286 .eval(
7287 r#"
7288 if (globalThis.errMsg === "should_not_resolve") {
7289 throw new Error("Expected rejection for connection refused");
7290 }
7291 "#,
7292 )
7293 .await
7294 .expect("verify connection refused");
7295 });
7296 }
7297
7298 #[test]
7301 fn dispatcher_ui_spinner_method() {
7302 futures::executor::block_on(async {
7303 let runtime = Rc::new(
7304 PiJsRuntime::with_clock(DeterministicClock::new(0))
7305 .await
7306 .expect("runtime"),
7307 );
7308
7309 runtime
7310 .eval(
7311 r#"
7312 globalThis.result = null;
7313 pi.ui("spinner", { text: "Loading...", visible: true })
7314 .then((r) => { globalThis.result = r; });
7315 "#,
7316 )
7317 .await
7318 .expect("eval");
7319
7320 let requests = runtime.drain_hostcall_requests();
7321 assert_eq!(requests.len(), 1);
7322
7323 let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
7324 let ui_handler = Arc::new(TestUiHandler {
7325 captured: Arc::clone(&captured),
7326 response_value: serde_json::json!({ "acknowledged": true }),
7327 });
7328
7329 let dispatcher = ExtensionDispatcher::new(
7330 Rc::clone(&runtime),
7331 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
7332 Arc::new(HttpConnector::with_defaults()),
7333 Arc::new(NullSession),
7334 ui_handler,
7335 PathBuf::from("."),
7336 );
7337
7338 for request in requests {
7339 dispatcher.dispatch_and_complete(request).await;
7340 }
7341
7342 while runtime.has_pending() {
7343 runtime.tick().await.expect("tick");
7344 runtime.drain_microtasks().await.expect("microtasks");
7345 }
7346
7347 let reqs = captured
7348 .lock()
7349 .unwrap_or_else(std::sync::PoisonError::into_inner)
7350 .clone();
7351 assert_eq!(reqs.len(), 1);
7352 assert_eq!(reqs[0].method, "spinner");
7353 assert_eq!(reqs[0].payload["text"], "Loading...");
7354 });
7355 }
7356
7357 #[test]
7358 fn dispatcher_ui_progress_method() {
7359 futures::executor::block_on(async {
7360 let runtime = Rc::new(
7361 PiJsRuntime::with_clock(DeterministicClock::new(0))
7362 .await
7363 .expect("runtime"),
7364 );
7365
7366 runtime
7367 .eval(
7368 r#"
7369 globalThis.result = null;
7370 pi.ui("progress", { current: 50, total: 100, label: "Processing" })
7371 .then((r) => { globalThis.result = r; });
7372 "#,
7373 )
7374 .await
7375 .expect("eval");
7376
7377 let requests = runtime.drain_hostcall_requests();
7378 assert_eq!(requests.len(), 1);
7379
7380 let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
7381 let ui_handler = Arc::new(TestUiHandler {
7382 captured: Arc::clone(&captured),
7383 response_value: Value::Null,
7384 });
7385
7386 let dispatcher = ExtensionDispatcher::new(
7387 Rc::clone(&runtime),
7388 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
7389 Arc::new(HttpConnector::with_defaults()),
7390 Arc::new(NullSession),
7391 ui_handler,
7392 PathBuf::from("."),
7393 );
7394
7395 for request in requests {
7396 dispatcher.dispatch_and_complete(request).await;
7397 }
7398
7399 while runtime.has_pending() {
7400 runtime.tick().await.expect("tick");
7401 runtime.drain_microtasks().await.expect("microtasks");
7402 }
7403
7404 let reqs = captured
7405 .lock()
7406 .unwrap_or_else(std::sync::PoisonError::into_inner)
7407 .clone();
7408 assert_eq!(reqs.len(), 1);
7409 assert_eq!(reqs[0].method, "progress");
7410 assert_eq!(reqs[0].payload["current"], 50);
7411 assert_eq!(reqs[0].payload["total"], 100);
7412 });
7413 }
7414
7415 #[test]
7416 fn dispatcher_ui_notification_method() {
7417 futures::executor::block_on(async {
7418 let runtime = Rc::new(
7419 PiJsRuntime::with_clock(DeterministicClock::new(0))
7420 .await
7421 .expect("runtime"),
7422 );
7423
7424 runtime
7425 .eval(
7426 r#"
7427 globalThis.result = null;
7428 pi.ui("notification", { message: "Task complete!", level: "info" })
7429 .then((r) => { globalThis.result = r; });
7430 "#,
7431 )
7432 .await
7433 .expect("eval");
7434
7435 let requests = runtime.drain_hostcall_requests();
7436 assert_eq!(requests.len(), 1);
7437
7438 let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
7439 let ui_handler = Arc::new(TestUiHandler {
7440 captured: Arc::clone(&captured),
7441 response_value: serde_json::json!({ "shown": true }),
7442 });
7443
7444 let dispatcher = ExtensionDispatcher::new(
7445 Rc::clone(&runtime),
7446 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
7447 Arc::new(HttpConnector::with_defaults()),
7448 Arc::new(NullSession),
7449 ui_handler,
7450 PathBuf::from("."),
7451 );
7452
7453 for request in requests {
7454 dispatcher.dispatch_and_complete(request).await;
7455 }
7456
7457 while runtime.has_pending() {
7458 runtime.tick().await.expect("tick");
7459 runtime.drain_microtasks().await.expect("microtasks");
7460 }
7461
7462 let reqs = captured
7463 .lock()
7464 .unwrap_or_else(std::sync::PoisonError::into_inner)
7465 .clone();
7466 assert_eq!(reqs.len(), 1);
7467 assert_eq!(reqs[0].method, "notification");
7468 assert_eq!(reqs[0].payload["message"], "Task complete!");
7469 assert_eq!(reqs[0].payload["level"], "info");
7470 });
7471 }
7472
7473 #[test]
7474 fn dispatcher_ui_null_handler_returns_null() {
7475 futures::executor::block_on(async {
7476 let runtime = Rc::new(
7477 PiJsRuntime::with_clock(DeterministicClock::new(0))
7478 .await
7479 .expect("runtime"),
7480 );
7481
7482 runtime
7483 .eval(
7484 r#"
7485 globalThis.result = "__unset__";
7486 pi.ui("any_method", { key: "value" })
7487 .then((r) => { globalThis.result = r; });
7488 "#,
7489 )
7490 .await
7491 .expect("eval");
7492
7493 let requests = runtime.drain_hostcall_requests();
7494 assert_eq!(requests.len(), 1);
7495
7496 let dispatcher = build_dispatcher(Rc::clone(&runtime));
7498 for request in requests {
7499 dispatcher.dispatch_and_complete(request).await;
7500 }
7501
7502 while runtime.has_pending() {
7503 runtime.tick().await.expect("tick");
7504 runtime.drain_microtasks().await.expect("microtasks");
7505 }
7506
7507 runtime
7508 .eval(
7509 r#"
7510 if (globalThis.result === "__unset__") throw new Error("UI not resolved");
7511 if (globalThis.result !== null) {
7512 throw new Error("Expected null from NullHandler, got: " + JSON.stringify(globalThis.result));
7513 }
7514 "#,
7515 )
7516 .await
7517 .expect("verify null UI handler");
7518 });
7519 }
7520
7521 #[test]
7522 fn dispatcher_ui_multiple_calls_captured() {
7523 futures::executor::block_on(async {
7524 let runtime = Rc::new(
7525 PiJsRuntime::with_clock(DeterministicClock::new(0))
7526 .await
7527 .expect("runtime"),
7528 );
7529
7530 runtime
7531 .eval(
7532 r#"
7533 globalThis.r1 = null;
7534 globalThis.r2 = null;
7535 pi.ui("set_status", { text: "Working..." })
7536 .then((r) => { globalThis.r1 = r; });
7537 pi.ui("set_widget", { lines: ["Line 1", "Line 2"] })
7538 .then((r) => { globalThis.r2 = r; });
7539 "#,
7540 )
7541 .await
7542 .expect("eval");
7543
7544 let requests = runtime.drain_hostcall_requests();
7545 assert_eq!(requests.len(), 2);
7546
7547 let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
7548 let ui_handler = Arc::new(TestUiHandler {
7549 captured: Arc::clone(&captured),
7550 response_value: Value::Null,
7551 });
7552
7553 let dispatcher = ExtensionDispatcher::new(
7554 Rc::clone(&runtime),
7555 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
7556 Arc::new(HttpConnector::with_defaults()),
7557 Arc::new(NullSession),
7558 ui_handler,
7559 PathBuf::from("."),
7560 );
7561
7562 for request in requests {
7563 dispatcher.dispatch_and_complete(request).await;
7564 }
7565
7566 while runtime.has_pending() {
7567 runtime.tick().await.expect("tick");
7568 runtime.drain_microtasks().await.expect("microtasks");
7569 }
7570
7571 let (len, methods) = {
7572 let reqs = captured
7573 .lock()
7574 .unwrap_or_else(std::sync::PoisonError::into_inner);
7575 let len = reqs.len();
7576 let methods = reqs.iter().map(|r| r.method.clone()).collect::<Vec<_>>();
7577 drop(reqs);
7578 (len, methods)
7579 };
7580 assert_eq!(len, 2);
7581 assert!(methods.iter().any(|method| method == "set_status"));
7582 assert!(methods.iter().any(|method| method == "set_widget"));
7583 });
7584 }
7585
7586 #[test]
7589 fn dispatcher_exec_with_custom_cwd() {
7590 futures::executor::block_on(async {
7591 let runtime = Rc::new(
7592 PiJsRuntime::with_clock(DeterministicClock::new(0))
7593 .await
7594 .expect("runtime"),
7595 );
7596
7597 runtime
7598 .eval(
7599 r#"
7600 globalThis.result = null;
7601 pi.exec("pwd", { cwd: "/tmp" })
7602 .then((r) => { globalThis.result = r; })
7603 .catch((e) => { globalThis.result = { error: e.message || String(e) }; });
7604 "#,
7605 )
7606 .await
7607 .expect("eval");
7608
7609 let requests = runtime.drain_hostcall_requests();
7610 assert_eq!(requests.len(), 1);
7611
7612 let dispatcher = build_dispatcher(Rc::clone(&runtime));
7613 for request in requests {
7614 dispatcher.dispatch_and_complete(request).await;
7615 }
7616
7617 while runtime.has_pending() {
7618 runtime.tick().await.expect("tick");
7619 runtime.drain_microtasks().await.expect("microtasks");
7620 }
7621
7622 runtime
7623 .eval(
7624 r#"
7625 if (!globalThis.result) throw new Error("exec not resolved");
7626 // Either it resolved to stdout containing /tmp, or it
7627 // was rejected - both are valid dispatcher behaviors.
7628 // Key assertion: the dispatcher didn't panic.
7629 "#,
7630 )
7631 .await
7632 .expect("verify exec cwd");
7633 });
7634 }
7635
7636 #[test]
7637 fn dispatcher_exec_empty_command_rejects() {
7638 futures::executor::block_on(async {
7639 let runtime = Rc::new(
7640 PiJsRuntime::with_clock(DeterministicClock::new(0))
7641 .await
7642 .expect("runtime"),
7643 );
7644
7645 runtime
7646 .eval(
7647 r#"
7648 globalThis.errMsg = "";
7649 pi.exec("")
7650 .then(() => { globalThis.errMsg = "should_not_resolve"; })
7651 .catch((e) => { globalThis.errMsg = e.message || String(e); });
7652 "#,
7653 )
7654 .await
7655 .expect("eval");
7656
7657 let requests = runtime.drain_hostcall_requests();
7658 assert_eq!(requests.len(), 1);
7659
7660 let dispatcher = build_dispatcher(Rc::clone(&runtime));
7661 for request in requests {
7662 dispatcher.dispatch_and_complete(request).await;
7663 }
7664
7665 while runtime.has_pending() {
7666 runtime.tick().await.expect("tick");
7667 runtime.drain_microtasks().await.expect("microtasks");
7668 }
7669
7670 runtime
7671 .eval(
7672 r#"
7673 if (globalThis.errMsg === "should_not_resolve") {
7674 throw new Error("Expected rejection for empty command");
7675 }
7676 // Empty command should produce some kind of error
7677 if (!globalThis.errMsg) {
7678 throw new Error("Expected error message");
7679 }
7680 "#,
7681 )
7682 .await
7683 .expect("verify empty command rejection");
7684 });
7685 }
7686
7687 #[test]
7690 fn dispatcher_events_emit_missing_event_name_rejects() {
7691 futures::executor::block_on(async {
7692 let runtime = Rc::new(
7693 PiJsRuntime::with_clock(DeterministicClock::new(0))
7694 .await
7695 .expect("runtime"),
7696 );
7697
7698 runtime
7699 .eval(
7700 r#"
7701 globalThis.errMsg = "";
7702 pi.events("emit", {})
7703 .then(() => { globalThis.errMsg = "should_not_resolve"; })
7704 .catch((e) => { globalThis.errMsg = e.message || String(e); });
7705 "#,
7706 )
7707 .await
7708 .expect("eval");
7709
7710 let requests = runtime.drain_hostcall_requests();
7711 assert_eq!(requests.len(), 1);
7712
7713 let dispatcher = build_dispatcher(Rc::clone(&runtime));
7714 for request in requests {
7715 dispatcher.dispatch_and_complete(request).await;
7716 }
7717
7718 while runtime.has_pending() {
7719 runtime.tick().await.expect("tick");
7720 runtime.drain_microtasks().await.expect("microtasks");
7721 }
7722
7723 runtime
7724 .eval(
7725 r#"
7726 // Should either reject or produce an error - not silently succeed
7727 if (globalThis.errMsg === "should_not_resolve") {
7728 // It's also acceptable if emit with empty payload succeeds gracefully
7729 }
7730 "#,
7731 )
7732 .await
7733 .expect("verify events emit");
7734 });
7735 }
7736
7737 #[test]
7738 fn dispatcher_events_list_empty_when_no_hooks() {
7739 futures::executor::block_on(async {
7740 let runtime = Rc::new(
7741 PiJsRuntime::with_clock(DeterministicClock::new(0))
7742 .await
7743 .expect("runtime"),
7744 );
7745
7746 runtime
7748 .eval(
7749 r#"
7750 globalThis.result = null;
7751 __pi_begin_extension("ext.empty", { name: "ext.empty" });
7752 pi.events("list", {})
7753 .then((r) => { globalThis.result = r; })
7754 .catch((e) => { globalThis.result = { error: e.message || String(e) }; });
7755 __pi_end_extension();
7756 "#,
7757 )
7758 .await
7759 .expect("eval");
7760
7761 let requests = runtime.drain_hostcall_requests();
7762 assert_eq!(requests.len(), 1);
7763
7764 let dispatcher = build_dispatcher(Rc::clone(&runtime));
7765 for request in requests {
7766 dispatcher.dispatch_and_complete(request).await;
7767 }
7768
7769 while runtime.has_pending() {
7770 runtime.tick().await.expect("tick");
7771 runtime.drain_microtasks().await.expect("microtasks");
7772 }
7773
7774 runtime
7775 .eval(
7776 r#"
7777 if (!globalThis.result) throw new Error("events list not resolved");
7778 // Result is { events: [...] }
7779 const events = globalThis.result.events;
7780 if (!Array.isArray(events)) {
7781 throw new Error("Expected events array, got: " + JSON.stringify(globalThis.result));
7782 }
7783 if (events.length !== 0) {
7784 throw new Error("Expected empty events list, got: " + JSON.stringify(events));
7785 }
7786 "#,
7787 )
7788 .await
7789 .expect("verify events list empty");
7790 });
7791 }
7792
7793 #[test]
7796 fn dispatcher_session_get_file_isolated() {
7797 futures::executor::block_on(async {
7798 let runtime = Rc::new(
7799 PiJsRuntime::with_clock(DeterministicClock::new(0))
7800 .await
7801 .expect("runtime"),
7802 );
7803
7804 runtime
7805 .eval(
7806 r#"
7807 globalThis.file = "__unset__";
7808 pi.session("get_file", {})
7809 .then((r) => { globalThis.file = r; });
7810 "#,
7811 )
7812 .await
7813 .expect("eval");
7814
7815 let requests = runtime.drain_hostcall_requests();
7816 assert_eq!(requests.len(), 1);
7817
7818 let state = Arc::new(Mutex::new(serde_json::json!({
7819 "sessionFile": "/home/user/.pi/sessions/abc.json"
7820 })));
7821 let session = Arc::new(TestSession {
7822 state,
7823 messages: Arc::new(Mutex::new(Vec::new())),
7824 entries: Arc::new(Mutex::new(Vec::new())),
7825 branch: Arc::new(Mutex::new(Vec::new())),
7826 name: Arc::new(Mutex::new(None)),
7827 custom_entries: Arc::new(Mutex::new(Vec::new())),
7828 labels: Arc::new(Mutex::new(Vec::new())),
7829 });
7830
7831 let dispatcher = ExtensionDispatcher::new(
7832 Rc::clone(&runtime),
7833 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
7834 Arc::new(HttpConnector::with_defaults()),
7835 session,
7836 Arc::new(NullUiHandler),
7837 PathBuf::from("."),
7838 );
7839
7840 for request in requests {
7841 dispatcher.dispatch_and_complete(request).await;
7842 }
7843
7844 while runtime.has_pending() {
7845 runtime.tick().await.expect("tick");
7846 runtime.drain_microtasks().await.expect("microtasks");
7847 }
7848
7849 runtime
7850 .eval(
7851 r#"
7852 if (globalThis.file === "__unset__") throw new Error("get_file not resolved");
7853 if (globalThis.file !== "/home/user/.pi/sessions/abc.json") {
7854 throw new Error("Expected session file path, got: " + JSON.stringify(globalThis.file));
7855 }
7856 "#,
7857 )
7858 .await
7859 .expect("verify get_file");
7860 });
7861 }
7862
7863 #[test]
7864 fn dispatcher_session_get_name_isolated() {
7865 futures::executor::block_on(async {
7866 let runtime = Rc::new(
7867 PiJsRuntime::with_clock(DeterministicClock::new(0))
7868 .await
7869 .expect("runtime"),
7870 );
7871
7872 runtime
7873 .eval(
7874 r#"
7875 globalThis.name = "__unset__";
7876 pi.session("get_name", {})
7877 .then((r) => { globalThis.name = r; });
7878 "#,
7879 )
7880 .await
7881 .expect("eval");
7882
7883 let requests = runtime.drain_hostcall_requests();
7884 assert_eq!(requests.len(), 1);
7885
7886 let state = Arc::new(Mutex::new(serde_json::json!({
7887 "sessionName": "My Debug Session"
7888 })));
7889 let session = Arc::new(TestSession {
7890 state,
7891 messages: Arc::new(Mutex::new(Vec::new())),
7892 entries: Arc::new(Mutex::new(Vec::new())),
7893 branch: Arc::new(Mutex::new(Vec::new())),
7894 name: Arc::new(Mutex::new(Some("My Debug Session".to_string()))),
7895 custom_entries: Arc::new(Mutex::new(Vec::new())),
7896 labels: Arc::new(Mutex::new(Vec::new())),
7897 });
7898
7899 let dispatcher = ExtensionDispatcher::new(
7900 Rc::clone(&runtime),
7901 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
7902 Arc::new(HttpConnector::with_defaults()),
7903 session,
7904 Arc::new(NullUiHandler),
7905 PathBuf::from("."),
7906 );
7907
7908 for request in requests {
7909 dispatcher.dispatch_and_complete(request).await;
7910 }
7911
7912 while runtime.has_pending() {
7913 runtime.tick().await.expect("tick");
7914 runtime.drain_microtasks().await.expect("microtasks");
7915 }
7916
7917 runtime
7918 .eval(
7919 r#"
7920 if (globalThis.name === "__unset__") throw new Error("get_name not resolved");
7921 if (globalThis.name !== "My Debug Session") {
7922 throw new Error("Expected session name, got: " + JSON.stringify(globalThis.name));
7923 }
7924 "#,
7925 )
7926 .await
7927 .expect("verify get_name");
7928 });
7929 }
7930
7931 #[test]
7932 fn dispatcher_session_append_entry_custom_type_edge_cases() {
7933 futures::executor::block_on(async {
7934 let runtime = Rc::new(
7935 PiJsRuntime::with_clock(DeterministicClock::new(0))
7936 .await
7937 .expect("runtime"),
7938 );
7939
7940 runtime
7942 .eval(
7943 r#"
7944 globalThis.result = "__unset__";
7945 pi.session("append_entry", {
7946 custom_type: "audit_log",
7947 data: { action: "login", ts: 1234567890 }
7948 }).then((r) => { globalThis.result = r; });
7949 "#,
7950 )
7951 .await
7952 .expect("eval");
7953
7954 let requests = runtime.drain_hostcall_requests();
7955 assert_eq!(requests.len(), 1);
7956
7957 let custom_entries: CustomEntries = Arc::new(Mutex::new(Vec::new()));
7958 let session = Arc::new(TestSession {
7959 state: Arc::new(Mutex::new(serde_json::json!({}))),
7960 messages: Arc::new(Mutex::new(Vec::new())),
7961 entries: Arc::new(Mutex::new(Vec::new())),
7962 branch: Arc::new(Mutex::new(Vec::new())),
7963 name: Arc::new(Mutex::new(None)),
7964 custom_entries: Arc::clone(&custom_entries),
7965 labels: Arc::new(Mutex::new(Vec::new())),
7966 });
7967
7968 let dispatcher = ExtensionDispatcher::new(
7969 Rc::clone(&runtime),
7970 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
7971 Arc::new(HttpConnector::with_defaults()),
7972 session,
7973 Arc::new(NullUiHandler),
7974 PathBuf::from("."),
7975 );
7976
7977 for request in requests {
7978 dispatcher.dispatch_and_complete(request).await;
7979 }
7980
7981 while runtime.has_pending() {
7982 runtime.tick().await.expect("tick");
7983 runtime.drain_microtasks().await.expect("microtasks");
7984 }
7985
7986 let captured = custom_entries
7987 .lock()
7988 .unwrap_or_else(std::sync::PoisonError::into_inner);
7989 assert_eq!(captured.len(), 1);
7990 assert_eq!(captured[0].0, "audit_log");
7991 assert!(captured[0].1.is_some());
7992 let data = captured[0].1.as_ref().unwrap().clone();
7993 drop(captured);
7994 assert_eq!(data["action"], "login");
7995 });
7996 }
7997
7998 #[test]
7999 fn dispatcher_events_emit_dispatches_custom_event() {
8000 futures::executor::block_on(async {
8001 let runtime = Rc::new(
8002 PiJsRuntime::with_clock(DeterministicClock::new(0))
8003 .await
8004 .expect("runtime"),
8005 );
8006
8007 runtime
8008 .eval(
8009 r#"
8010 globalThis.seen = [];
8011 globalThis.emitResult = null;
8012
8013 __pi_begin_extension("ext.b", { name: "ext.b" });
8014 pi.on("custom_event", (payload, _ctx) => { globalThis.seen.push(payload); });
8015 __pi_end_extension();
8016
8017 __pi_begin_extension("ext.a", { name: "ext.a" });
8018 pi.events("emit", { event: "custom_event", data: { hello: "world" } })
8019 .then((r) => { globalThis.emitResult = r; });
8020 __pi_end_extension();
8021 "#,
8022 )
8023 .await
8024 .expect("eval");
8025
8026 let requests = runtime.drain_hostcall_requests();
8027 assert_eq!(requests.len(), 1);
8028
8029 let dispatcher = build_dispatcher(Rc::clone(&runtime));
8030 for request in requests {
8031 dispatcher.dispatch_and_complete(request).await;
8032 }
8033
8034 runtime.tick().await.expect("tick");
8035
8036 runtime
8037 .eval(
8038 r#"
8039 if (!globalThis.emitResult) throw new Error("emit promise not resolved");
8040 if (globalThis.emitResult.dispatched !== true) {
8041 throw new Error("emit did not report dispatched: " + JSON.stringify(globalThis.emitResult));
8042 }
8043 if (globalThis.emitResult.event !== "custom_event") {
8044 throw new Error("wrong event: " + JSON.stringify(globalThis.emitResult));
8045 }
8046 if (!Array.isArray(globalThis.seen) || globalThis.seen.length !== 1) {
8047 throw new Error("event handler not called: " + JSON.stringify(globalThis.seen));
8048 }
8049 const payload = globalThis.seen[0];
8050 if (!payload || payload.hello !== "world") {
8051 throw new Error("wrong payload: " + JSON.stringify(payload));
8052 }
8053 "#,
8054 )
8055 .await
8056 .expect("verify emit");
8057 });
8058 }
8059
8060 #[test]
8065 #[cfg(unix)]
8066 fn dispatcher_exec_with_args_array() {
8067 futures::executor::block_on(async {
8068 let runtime = Rc::new(
8069 PiJsRuntime::with_clock(DeterministicClock::new(0))
8070 .await
8071 .expect("runtime"),
8072 );
8073
8074 runtime
8076 .eval(
8077 r#"
8078 globalThis.result = null;
8079 pi.exec("/bin/echo", ["hello", "world"], {})
8080 .then((r) => { globalThis.result = r; })
8081 .catch((e) => { globalThis.result = { error: e.message || String(e) }; });
8082 "#,
8083 )
8084 .await
8085 .expect("eval");
8086
8087 let requests = runtime.drain_hostcall_requests();
8088 assert_eq!(requests.len(), 1);
8089
8090 let dispatcher = build_dispatcher(Rc::clone(&runtime));
8091 for request in requests {
8092 dispatcher.dispatch_and_complete(request).await;
8093 }
8094
8095 while runtime.has_pending() {
8096 runtime.tick().await.expect("tick");
8097 runtime.drain_microtasks().await.expect("microtasks");
8098 }
8099
8100 runtime
8101 .eval(
8102 r#"
8103 if (!globalThis.result) throw new Error("exec not resolved");
8104 if (globalThis.result.error) throw new Error("exec errored: " + globalThis.result.error);
8105 if (typeof globalThis.result.stdout !== "string") {
8106 throw new Error("Expected stdout string, got: " + JSON.stringify(globalThis.result));
8107 }
8108 if (!globalThis.result.stdout.includes("hello") || !globalThis.result.stdout.includes("world")) {
8109 throw new Error("Expected 'hello world' in stdout, got: " + globalThis.result.stdout);
8110 }
8111 "#,
8112 )
8113 .await
8114 .expect("verify exec with args");
8115 });
8116 }
8117
8118 #[test]
8119 #[cfg(unix)]
8120 fn dispatcher_exec_null_args_defaults_to_empty() {
8121 futures::executor::block_on(async {
8122 let runtime = Rc::new(
8123 PiJsRuntime::with_clock(DeterministicClock::new(0))
8124 .await
8125 .expect("runtime"),
8126 );
8127
8128 runtime
8129 .eval(
8130 r#"
8131 globalThis.result = null;
8132 pi.exec("/bin/echo")
8133 .then((r) => { globalThis.result = r; })
8134 .catch((e) => { globalThis.result = { error: e.message || String(e) }; });
8135 "#,
8136 )
8137 .await
8138 .expect("eval");
8139
8140 let requests = runtime.drain_hostcall_requests();
8141 assert_eq!(requests.len(), 1);
8142
8143 let dispatcher = build_dispatcher(Rc::clone(&runtime));
8144 for request in requests {
8145 dispatcher.dispatch_and_complete(request).await;
8146 }
8147
8148 while runtime.has_pending() {
8149 runtime.tick().await.expect("tick");
8150 runtime.drain_microtasks().await.expect("microtasks");
8151 }
8152
8153 runtime
8154 .eval(
8155 r#"
8156 if (!globalThis.result) throw new Error("exec not resolved");
8157 // echo with no args produces empty or newline stdout
8158 if (globalThis.result.error) throw new Error("exec errored: " + globalThis.result.error);
8159 if (typeof globalThis.result.stdout !== "string") {
8160 throw new Error("Expected stdout string");
8161 }
8162 "#,
8163 )
8164 .await
8165 .expect("verify exec null args");
8166 });
8167 }
8168
8169 #[test]
8170 fn dispatcher_exec_non_array_args_rejects() {
8171 futures::executor::block_on(async {
8172 let runtime = Rc::new(
8173 PiJsRuntime::with_clock(DeterministicClock::new(0))
8174 .await
8175 .expect("runtime"),
8176 );
8177
8178 runtime
8179 .eval(
8180 r#"
8181 globalThis.errMsg = "";
8182 pi.exec("echo", "not-an-array", {})
8183 .then(() => { globalThis.errMsg = "should_not_resolve"; })
8184 .catch((e) => { globalThis.errMsg = e.message || String(e); });
8185 "#,
8186 )
8187 .await
8188 .expect("eval");
8189
8190 let requests = runtime.drain_hostcall_requests();
8191 assert_eq!(requests.len(), 1);
8192
8193 let dispatcher = build_dispatcher(Rc::clone(&runtime));
8194 for request in requests {
8195 dispatcher.dispatch_and_complete(request).await;
8196 }
8197
8198 while runtime.has_pending() {
8199 runtime.tick().await.expect("tick");
8200 runtime.drain_microtasks().await.expect("microtasks");
8201 }
8202
8203 runtime
8204 .eval(
8205 r#"
8206 if (globalThis.errMsg === "should_not_resolve") {
8207 throw new Error("Expected rejection for non-array args");
8208 }
8209 if (!globalThis.errMsg.toLowerCase().includes("array")) {
8210 throw new Error("Expected error about array, got: " + globalThis.errMsg);
8211 }
8212 "#,
8213 )
8214 .await
8215 .expect("verify non-array args rejection");
8216 });
8217 }
8218
8219 #[test]
8220 #[cfg(unix)]
8221 fn dispatcher_exec_captures_stdout_and_stderr() {
8222 futures::executor::block_on(async {
8223 let runtime = Rc::new(
8224 PiJsRuntime::with_clock(DeterministicClock::new(0))
8225 .await
8226 .expect("runtime"),
8227 );
8228
8229 runtime
8231 .eval(
8232 r#"
8233 globalThis.result = null;
8234 pi.exec("/bin/sh", ["-c", "echo OUT && echo ERR >&2"], {})
8235 .then((r) => { globalThis.result = r; })
8236 .catch((e) => { globalThis.result = { error: e.message || String(e) }; });
8237 "#,
8238 )
8239 .await
8240 .expect("eval");
8241
8242 let requests = runtime.drain_hostcall_requests();
8243 assert_eq!(requests.len(), 1);
8244
8245 let dispatcher = build_dispatcher(Rc::clone(&runtime));
8246 for request in requests {
8247 dispatcher.dispatch_and_complete(request).await;
8248 }
8249
8250 while runtime.has_pending() {
8251 runtime.tick().await.expect("tick");
8252 runtime.drain_microtasks().await.expect("microtasks");
8253 }
8254
8255 runtime
8256 .eval(
8257 r#"
8258 if (!globalThis.result) throw new Error("exec not resolved");
8259 if (globalThis.result.error) throw new Error("exec errored: " + globalThis.result.error);
8260 if (!globalThis.result.stdout.includes("OUT")) {
8261 throw new Error("Expected 'OUT' in stdout, got: " + globalThis.result.stdout);
8262 }
8263 if (!globalThis.result.stderr.includes("ERR")) {
8264 throw new Error("Expected 'ERR' in stderr, got: " + globalThis.result.stderr);
8265 }
8266 "#,
8267 )
8268 .await
8269 .expect("verify stdout and stderr capture");
8270 });
8271 }
8272
8273 #[test]
8274 #[cfg(unix)]
8275 fn dispatcher_exec_nonzero_exit_code() {
8276 futures::executor::block_on(async {
8277 let runtime = Rc::new(
8278 PiJsRuntime::with_clock(DeterministicClock::new(0))
8279 .await
8280 .expect("runtime"),
8281 );
8282
8283 runtime
8284 .eval(
8285 r#"
8286 globalThis.result = null;
8287 pi.exec("/bin/sh", ["-c", "exit 42"], {})
8288 .then((r) => { globalThis.result = r; })
8289 .catch((e) => { globalThis.result = { error: e.message || String(e) }; });
8290 "#,
8291 )
8292 .await
8293 .expect("eval");
8294
8295 let requests = runtime.drain_hostcall_requests();
8296 assert_eq!(requests.len(), 1);
8297
8298 let dispatcher = build_dispatcher(Rc::clone(&runtime));
8299 for request in requests {
8300 dispatcher.dispatch_and_complete(request).await;
8301 }
8302
8303 while runtime.has_pending() {
8304 runtime.tick().await.expect("tick");
8305 runtime.drain_microtasks().await.expect("microtasks");
8306 }
8307
8308 runtime
8309 .eval(
8310 r#"
8311 if (!globalThis.result) throw new Error("exec not resolved");
8312 if (globalThis.result.error) throw new Error("exec errored: " + globalThis.result.error);
8313 if (globalThis.result.code !== 42) {
8314 throw new Error("Expected exit code 42, got: " + globalThis.result.code);
8315 }
8316 "#,
8317 )
8318 .await
8319 .expect("verify nonzero exit code");
8320 });
8321 }
8322
8323 #[cfg(unix)]
8324 #[test]
8325 fn dispatcher_exec_signal_termination_reports_nonzero_code() {
8326 futures::executor::block_on(async {
8327 let runtime = Rc::new(
8328 PiJsRuntime::with_clock(DeterministicClock::new(0))
8329 .await
8330 .expect("runtime"),
8331 );
8332
8333 runtime
8334 .eval(
8335 r#"
8336 globalThis.result = null;
8337 pi.exec("/bin/sh", ["-c", "kill -KILL $$"], {})
8338 .then((r) => { globalThis.result = r; })
8339 .catch((e) => { globalThis.result = { error: e.message || String(e) }; });
8340 "#,
8341 )
8342 .await
8343 .expect("eval");
8344
8345 let requests = runtime.drain_hostcall_requests();
8346 assert_eq!(requests.len(), 1);
8347
8348 let dispatcher = build_dispatcher(Rc::clone(&runtime));
8349 for request in requests {
8350 dispatcher.dispatch_and_complete(request).await;
8351 }
8352
8353 while runtime.has_pending() {
8354 runtime.tick().await.expect("tick");
8355 runtime.drain_microtasks().await.expect("microtasks");
8356 }
8357
8358 runtime
8359 .eval(
8360 r#"
8361 if (!globalThis.result) throw new Error("exec not resolved");
8362 if (globalThis.result.error) throw new Error("exec errored: " + globalThis.result.error);
8363 if (globalThis.result.code === 0) {
8364 throw new Error("Expected non-zero exit code for signal termination, got: " + globalThis.result.code);
8365 }
8366 "#,
8367 )
8368 .await
8369 .expect("verify signal termination exit code");
8370 });
8371 }
8372
8373 #[test]
8374 fn dispatcher_exec_command_not_found_rejects() {
8375 futures::executor::block_on(async {
8376 let runtime = Rc::new(
8377 PiJsRuntime::with_clock(DeterministicClock::new(0))
8378 .await
8379 .expect("runtime"),
8380 );
8381
8382 runtime
8383 .eval(
8384 r#"
8385 globalThis.errMsg = "";
8386 pi.exec("__nonexistent_command_xyz__")
8387 .then(() => { globalThis.errMsg = "should_not_resolve"; })
8388 .catch((e) => { globalThis.errMsg = e.message || String(e); });
8389 "#,
8390 )
8391 .await
8392 .expect("eval");
8393
8394 let requests = runtime.drain_hostcall_requests();
8395 assert_eq!(requests.len(), 1);
8396
8397 let dispatcher = build_dispatcher(Rc::clone(&runtime));
8398 for request in requests {
8399 dispatcher.dispatch_and_complete(request).await;
8400 }
8401
8402 while runtime.has_pending() {
8403 runtime.tick().await.expect("tick");
8404 runtime.drain_microtasks().await.expect("microtasks");
8405 }
8406
8407 runtime
8408 .eval(
8409 r#"
8410 if (globalThis.errMsg === "should_not_resolve") {
8411 throw new Error("Expected rejection for nonexistent command");
8412 }
8413 if (!globalThis.errMsg) {
8414 throw new Error("Expected error message for nonexistent command");
8415 }
8416 "#,
8417 )
8418 .await
8419 .expect("verify command not found rejection");
8420 });
8421 }
8422
8423 #[test]
8426 fn dispatcher_http_tls_required_rejects_http_url() {
8427 futures::executor::block_on(async {
8428 let runtime = Rc::new(
8429 PiJsRuntime::with_clock(DeterministicClock::new(0))
8430 .await
8431 .expect("runtime"),
8432 );
8433
8434 runtime
8435 .eval(
8436 r#"
8437 globalThis.errMsg = "";
8438 pi.http({ url: "http://example.com/test", method: "GET" })
8439 .then(() => { globalThis.errMsg = "should_not_resolve"; })
8440 .catch((e) => { globalThis.errMsg = e.message || String(e); });
8441 "#,
8442 )
8443 .await
8444 .expect("eval");
8445
8446 let requests = runtime.drain_hostcall_requests();
8447 assert_eq!(requests.len(), 1);
8448
8449 let dispatcher = build_dispatcher(Rc::clone(&runtime));
8451 for request in requests {
8452 dispatcher.dispatch_and_complete(request).await;
8453 }
8454
8455 while runtime.has_pending() {
8456 runtime.tick().await.expect("tick");
8457 runtime.drain_microtasks().await.expect("microtasks");
8458 }
8459
8460 runtime
8461 .eval(
8462 r#"
8463 if (globalThis.errMsg === "should_not_resolve") {
8464 throw new Error("Expected rejection for http:// URL when TLS required");
8465 }
8466 if (!globalThis.errMsg.toLowerCase().includes("tls") &&
8467 !globalThis.errMsg.toLowerCase().includes("https")) {
8468 throw new Error("Expected TLS-related error, got: " + globalThis.errMsg);
8469 }
8470 "#,
8471 )
8472 .await
8473 .expect("verify TLS enforcement");
8474 });
8475 }
8476
8477 #[test]
8478 fn dispatcher_http_invalid_url_format_rejects() {
8479 futures::executor::block_on(async {
8480 let runtime = Rc::new(
8481 PiJsRuntime::with_clock(DeterministicClock::new(0))
8482 .await
8483 .expect("runtime"),
8484 );
8485
8486 runtime
8487 .eval(
8488 r#"
8489 globalThis.errMsg = "";
8490 pi.http({ url: "not-a-valid-url", method: "GET" })
8491 .then(() => { globalThis.errMsg = "should_not_resolve"; })
8492 .catch((e) => { globalThis.errMsg = e.message || String(e); });
8493 "#,
8494 )
8495 .await
8496 .expect("eval");
8497
8498 let requests = runtime.drain_hostcall_requests();
8499 assert_eq!(requests.len(), 1);
8500
8501 let http_connector = HttpConnector::new(HttpConnectorConfig {
8502 require_tls: false,
8503 ..Default::default()
8504 });
8505 let dispatcher = ExtensionDispatcher::new(
8506 Rc::clone(&runtime),
8507 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
8508 Arc::new(http_connector),
8509 Arc::new(NullSession),
8510 Arc::new(NullUiHandler),
8511 PathBuf::from("."),
8512 );
8513
8514 for request in requests {
8515 dispatcher.dispatch_and_complete(request).await;
8516 }
8517
8518 while runtime.has_pending() {
8519 runtime.tick().await.expect("tick");
8520 runtime.drain_microtasks().await.expect("microtasks");
8521 }
8522
8523 runtime
8524 .eval(
8525 r#"
8526 if (globalThis.errMsg === "should_not_resolve") {
8527 throw new Error("Expected rejection for invalid URL");
8528 }
8529 if (!globalThis.errMsg) {
8530 throw new Error("Expected error message for invalid URL");
8531 }
8532 "#,
8533 )
8534 .await
8535 .expect("verify invalid URL rejection");
8536 });
8537 }
8538
8539 #[test]
8540 fn dispatcher_http_get_with_body_rejects() {
8541 futures::executor::block_on(async {
8542 let runtime = Rc::new(
8543 PiJsRuntime::with_clock(DeterministicClock::new(0))
8544 .await
8545 .expect("runtime"),
8546 );
8547
8548 runtime
8549 .eval(
8550 r#"
8551 globalThis.errMsg = "";
8552 pi.http({ url: "https://example.com/test", method: "GET", body: "should-not-have-body" })
8553 .then(() => { globalThis.errMsg = "should_not_resolve"; })
8554 .catch((e) => { globalThis.errMsg = e.message || String(e); });
8555 "#,
8556 )
8557 .await
8558 .expect("eval");
8559
8560 let requests = runtime.drain_hostcall_requests();
8561 assert_eq!(requests.len(), 1);
8562
8563 let dispatcher = build_dispatcher(Rc::clone(&runtime));
8564 for request in requests {
8565 dispatcher.dispatch_and_complete(request).await;
8566 }
8567
8568 while runtime.has_pending() {
8569 runtime.tick().await.expect("tick");
8570 runtime.drain_microtasks().await.expect("microtasks");
8571 }
8572
8573 runtime
8574 .eval(
8575 r#"
8576 if (globalThis.errMsg === "should_not_resolve") {
8577 throw new Error("Expected rejection for GET with body");
8578 }
8579 if (!globalThis.errMsg.toLowerCase().includes("body") &&
8580 !globalThis.errMsg.toLowerCase().includes("get")) {
8581 throw new Error("Expected body/GET error, got: " + globalThis.errMsg);
8582 }
8583 "#,
8584 )
8585 .await
8586 .expect("verify GET with body rejection");
8587 });
8588 }
8589
8590 #[test]
8591 fn dispatcher_http_response_body_returned() {
8592 futures::executor::block_on(async {
8593 let addr = spawn_http_server_with_status(200, "response-body-content");
8594 let url = format!("http://{addr}/body-test");
8595
8596 let runtime = Rc::new(
8597 PiJsRuntime::with_clock(DeterministicClock::new(0))
8598 .await
8599 .expect("runtime"),
8600 );
8601
8602 let script = format!(
8603 r#"
8604 globalThis.result = null;
8605 pi.http({{ url: "{url}", method: "GET" }})
8606 .then((r) => {{ globalThis.result = r; }})
8607 .catch((e) => {{ globalThis.result = {{ error: e.message || String(e) }}; }});
8608 "#
8609 );
8610 runtime.eval(&script).await.expect("eval");
8611
8612 let requests = runtime.drain_hostcall_requests();
8613 assert_eq!(requests.len(), 1);
8614
8615 let http_connector = HttpConnector::new(HttpConnectorConfig {
8616 require_tls: false,
8617 ..Default::default()
8618 });
8619 let dispatcher = ExtensionDispatcher::new(
8620 Rc::clone(&runtime),
8621 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
8622 Arc::new(http_connector),
8623 Arc::new(NullSession),
8624 Arc::new(NullUiHandler),
8625 PathBuf::from("."),
8626 );
8627
8628 for request in requests {
8629 dispatcher.dispatch_and_complete(request).await;
8630 }
8631
8632 while runtime.has_pending() {
8633 runtime.tick().await.expect("tick");
8634 runtime.drain_microtasks().await.expect("microtasks");
8635 }
8636
8637 runtime
8638 .eval(
8639 r#"
8640 if (!globalThis.result) throw new Error("HTTP not resolved");
8641 if (globalThis.result.error) throw new Error("HTTP error: " + globalThis.result.error);
8642 if (globalThis.result.status !== 200) {
8643 throw new Error("Expected 200, got: " + globalThis.result.status);
8644 }
8645 const body = globalThis.result.body || "";
8646 if (!body.includes("response-body-content")) {
8647 throw new Error("Expected response body, got: " + body);
8648 }
8649 "#,
8650 )
8651 .await
8652 .expect("verify response body");
8653 });
8654 }
8655
8656 #[test]
8657 fn dispatcher_http_error_status_code_returned() {
8658 futures::executor::block_on(async {
8659 let addr = spawn_http_server_with_status(404, "not found");
8660 let url = format!("http://{addr}/missing");
8661
8662 let runtime = Rc::new(
8663 PiJsRuntime::with_clock(DeterministicClock::new(0))
8664 .await
8665 .expect("runtime"),
8666 );
8667
8668 let script = format!(
8669 r#"
8670 globalThis.result = null;
8671 pi.http({{ url: "{url}", method: "GET" }})
8672 .then((r) => {{ globalThis.result = r; }})
8673 .catch((e) => {{ globalThis.result = {{ error: e.message || String(e) }}; }});
8674 "#
8675 );
8676 runtime.eval(&script).await.expect("eval");
8677
8678 let requests = runtime.drain_hostcall_requests();
8679 assert_eq!(requests.len(), 1);
8680
8681 let http_connector = HttpConnector::new(HttpConnectorConfig {
8682 require_tls: false,
8683 ..Default::default()
8684 });
8685 let dispatcher = ExtensionDispatcher::new(
8686 Rc::clone(&runtime),
8687 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
8688 Arc::new(http_connector),
8689 Arc::new(NullSession),
8690 Arc::new(NullUiHandler),
8691 PathBuf::from("."),
8692 );
8693
8694 for request in requests {
8695 dispatcher.dispatch_and_complete(request).await;
8696 }
8697
8698 while runtime.has_pending() {
8699 runtime.tick().await.expect("tick");
8700 runtime.drain_microtasks().await.expect("microtasks");
8701 }
8702
8703 runtime
8704 .eval(
8705 r#"
8706 if (!globalThis.result) throw new Error("HTTP not resolved");
8707 // 404 should still resolve (not reject) with the status code
8708 if (globalThis.result.status !== 404) {
8709 throw new Error("Expected status 404, got: " + JSON.stringify(globalThis.result));
8710 }
8711 "#,
8712 )
8713 .await
8714 .expect("verify error status code");
8715 });
8716 }
8717
8718 #[test]
8719 fn dispatcher_http_unsupported_scheme_rejects() {
8720 futures::executor::block_on(async {
8721 let runtime = Rc::new(
8722 PiJsRuntime::with_clock(DeterministicClock::new(0))
8723 .await
8724 .expect("runtime"),
8725 );
8726
8727 runtime
8728 .eval(
8729 r#"
8730 globalThis.errMsg = "";
8731 pi.http({ url: "ftp://example.com/file", method: "GET" })
8732 .then(() => { globalThis.errMsg = "should_not_resolve"; })
8733 .catch((e) => { globalThis.errMsg = e.message || String(e); });
8734 "#,
8735 )
8736 .await
8737 .expect("eval");
8738
8739 let requests = runtime.drain_hostcall_requests();
8740 assert_eq!(requests.len(), 1);
8741
8742 let http_connector = HttpConnector::new(HttpConnectorConfig {
8743 require_tls: false,
8744 ..Default::default()
8745 });
8746 let dispatcher = ExtensionDispatcher::new(
8747 Rc::clone(&runtime),
8748 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
8749 Arc::new(http_connector),
8750 Arc::new(NullSession),
8751 Arc::new(NullUiHandler),
8752 PathBuf::from("."),
8753 );
8754
8755 for request in requests {
8756 dispatcher.dispatch_and_complete(request).await;
8757 }
8758
8759 while runtime.has_pending() {
8760 runtime.tick().await.expect("tick");
8761 runtime.drain_microtasks().await.expect("microtasks");
8762 }
8763
8764 runtime
8765 .eval(
8766 r#"
8767 if (globalThis.errMsg === "should_not_resolve") {
8768 throw new Error("Expected rejection for ftp:// scheme");
8769 }
8770 if (!globalThis.errMsg.toLowerCase().includes("scheme") &&
8771 !globalThis.errMsg.toLowerCase().includes("unsupported")) {
8772 throw new Error("Expected scheme error, got: " + globalThis.errMsg);
8773 }
8774 "#,
8775 )
8776 .await
8777 .expect("verify unsupported scheme rejection");
8778 });
8779 }
8780
8781 #[test]
8784 fn dispatcher_ui_arbitrary_method_passthrough() {
8785 futures::executor::block_on(async {
8786 let runtime = Rc::new(
8787 PiJsRuntime::with_clock(DeterministicClock::new(0))
8788 .await
8789 .expect("runtime"),
8790 );
8791
8792 runtime
8793 .eval(
8794 r#"
8795 globalThis.result = null;
8796 pi.ui("custom_op", { key: "value" })
8797 .then((r) => { globalThis.result = r; });
8798 "#,
8799 )
8800 .await
8801 .expect("eval");
8802
8803 let requests = runtime.drain_hostcall_requests();
8804 assert_eq!(requests.len(), 1);
8805
8806 let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
8807 let ui_handler = Arc::new(TestUiHandler {
8808 captured: Arc::clone(&captured),
8809 response_value: Value::Null,
8810 });
8811
8812 let dispatcher = ExtensionDispatcher::new(
8813 Rc::clone(&runtime),
8814 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
8815 Arc::new(HttpConnector::with_defaults()),
8816 Arc::new(NullSession),
8817 ui_handler,
8818 PathBuf::from("."),
8819 );
8820
8821 for request in requests {
8822 dispatcher.dispatch_and_complete(request).await;
8823 }
8824
8825 while runtime.has_pending() {
8826 runtime.tick().await.expect("tick");
8827 runtime.drain_microtasks().await.expect("microtasks");
8828 }
8829
8830 let reqs = captured
8831 .lock()
8832 .unwrap_or_else(std::sync::PoisonError::into_inner)
8833 .clone();
8834 assert_eq!(reqs.len(), 1);
8835 assert_eq!(reqs[0].method, "custom_op");
8836 assert_eq!(reqs[0].payload["key"], "value");
8837 });
8838 }
8839
8840 #[test]
8841 fn dispatcher_ui_payload_passthrough_complex() {
8842 futures::executor::block_on(async {
8843 let runtime = Rc::new(
8844 PiJsRuntime::with_clock(DeterministicClock::new(0))
8845 .await
8846 .expect("runtime"),
8847 );
8848
8849 runtime
8850 .eval(
8851 r#"
8852 globalThis.result = null;
8853 pi.ui("set_widget", {
8854 lines: [
8855 { text: "Line 1", style: { bold: true } },
8856 { text: "Line 2", style: { color: "red" } }
8857 ],
8858 content: "widget body",
8859 metadata: { nested: { deep: true } }
8860 }).then((r) => { globalThis.result = r; });
8861 "#,
8862 )
8863 .await
8864 .expect("eval");
8865
8866 let requests = runtime.drain_hostcall_requests();
8867 assert_eq!(requests.len(), 1);
8868
8869 let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
8870 let ui_handler = Arc::new(TestUiHandler {
8871 captured: Arc::clone(&captured),
8872 response_value: Value::Null,
8873 });
8874
8875 let dispatcher = ExtensionDispatcher::new(
8876 Rc::clone(&runtime),
8877 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
8878 Arc::new(HttpConnector::with_defaults()),
8879 Arc::new(NullSession),
8880 ui_handler,
8881 PathBuf::from("."),
8882 );
8883
8884 for request in requests {
8885 dispatcher.dispatch_and_complete(request).await;
8886 }
8887
8888 while runtime.has_pending() {
8889 runtime.tick().await.expect("tick");
8890 runtime.drain_microtasks().await.expect("microtasks");
8891 }
8892
8893 let reqs = captured
8894 .lock()
8895 .unwrap_or_else(std::sync::PoisonError::into_inner)
8896 .clone();
8897 assert_eq!(reqs.len(), 1);
8898 let payload = &reqs[0].payload;
8899 assert!(payload["lines"].is_array());
8900 assert_eq!(payload["lines"].as_array().unwrap().len(), 2);
8901 assert_eq!(payload["content"], "widget body");
8902 assert_eq!(payload["metadata"]["nested"]["deep"], true);
8903 });
8904 }
8905
8906 #[test]
8907 fn dispatcher_ui_handler_returns_value() {
8908 futures::executor::block_on(async {
8909 let runtime = Rc::new(
8910 PiJsRuntime::with_clock(DeterministicClock::new(0))
8911 .await
8912 .expect("runtime"),
8913 );
8914
8915 runtime
8916 .eval(
8917 r#"
8918 globalThis.result = "__unset__";
8919 pi.ui("get_input", { prompt: "Enter name" })
8920 .then((r) => { globalThis.result = r; });
8921 "#,
8922 )
8923 .await
8924 .expect("eval");
8925
8926 let requests = runtime.drain_hostcall_requests();
8927 assert_eq!(requests.len(), 1);
8928
8929 let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
8930 let ui_handler = Arc::new(TestUiHandler {
8931 captured: Arc::clone(&captured),
8932 response_value: serde_json::json!({ "input": "Alice", "confirmed": true }),
8933 });
8934
8935 let dispatcher = ExtensionDispatcher::new(
8936 Rc::clone(&runtime),
8937 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
8938 Arc::new(HttpConnector::with_defaults()),
8939 Arc::new(NullSession),
8940 ui_handler,
8941 PathBuf::from("."),
8942 );
8943
8944 for request in requests {
8945 dispatcher.dispatch_and_complete(request).await;
8946 }
8947
8948 while runtime.has_pending() {
8949 runtime.tick().await.expect("tick");
8950 runtime.drain_microtasks().await.expect("microtasks");
8951 }
8952
8953 runtime
8954 .eval(
8955 r#"
8956 if (globalThis.result === "__unset__") throw new Error("UI not resolved");
8957 if (globalThis.result.input !== "Alice") {
8958 throw new Error("Expected input 'Alice', got: " + JSON.stringify(globalThis.result));
8959 }
8960 if (globalThis.result.confirmed !== true) {
8961 throw new Error("Expected confirmed true");
8962 }
8963 "#,
8964 )
8965 .await
8966 .expect("verify UI handler value");
8967 });
8968 }
8969
8970 #[test]
8971 fn dispatcher_ui_set_status_empty_text() {
8972 futures::executor::block_on(async {
8973 let runtime = Rc::new(
8974 PiJsRuntime::with_clock(DeterministicClock::new(0))
8975 .await
8976 .expect("runtime"),
8977 );
8978
8979 runtime
8980 .eval(
8981 r#"
8982 globalThis.result = null;
8983 pi.ui("set_status", { text: "" })
8984 .then((r) => { globalThis.result = r; });
8985 "#,
8986 )
8987 .await
8988 .expect("eval");
8989
8990 let requests = runtime.drain_hostcall_requests();
8991 assert_eq!(requests.len(), 1);
8992
8993 let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
8994 let ui_handler = Arc::new(TestUiHandler {
8995 captured: Arc::clone(&captured),
8996 response_value: Value::Null,
8997 });
8998
8999 let dispatcher = ExtensionDispatcher::new(
9000 Rc::clone(&runtime),
9001 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
9002 Arc::new(HttpConnector::with_defaults()),
9003 Arc::new(NullSession),
9004 ui_handler,
9005 PathBuf::from("."),
9006 );
9007
9008 for request in requests {
9009 dispatcher.dispatch_and_complete(request).await;
9010 }
9011
9012 while runtime.has_pending() {
9013 runtime.tick().await.expect("tick");
9014 runtime.drain_microtasks().await.expect("microtasks");
9015 }
9016
9017 let reqs = captured
9018 .lock()
9019 .unwrap_or_else(std::sync::PoisonError::into_inner)
9020 .clone();
9021 assert_eq!(reqs.len(), 1);
9022 assert_eq!(reqs[0].method, "set_status");
9023 assert_eq!(reqs[0].payload["text"], "");
9024 });
9025 }
9026
9027 #[test]
9028 fn dispatcher_ui_empty_payload() {
9029 futures::executor::block_on(async {
9030 let runtime = Rc::new(
9031 PiJsRuntime::with_clock(DeterministicClock::new(0))
9032 .await
9033 .expect("runtime"),
9034 );
9035
9036 runtime
9037 .eval(
9038 r#"
9039 globalThis.result = null;
9040 pi.ui("dismiss", {})
9041 .then((r) => { globalThis.result = r; });
9042 "#,
9043 )
9044 .await
9045 .expect("eval");
9046
9047 let requests = runtime.drain_hostcall_requests();
9048 assert_eq!(requests.len(), 1);
9049
9050 let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
9051 let ui_handler = Arc::new(TestUiHandler {
9052 captured: Arc::clone(&captured),
9053 response_value: Value::Null,
9054 });
9055
9056 let dispatcher = ExtensionDispatcher::new(
9057 Rc::clone(&runtime),
9058 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
9059 Arc::new(HttpConnector::with_defaults()),
9060 Arc::new(NullSession),
9061 ui_handler,
9062 PathBuf::from("."),
9063 );
9064
9065 for request in requests {
9066 dispatcher.dispatch_and_complete(request).await;
9067 }
9068
9069 while runtime.has_pending() {
9070 runtime.tick().await.expect("tick");
9071 runtime.drain_microtasks().await.expect("microtasks");
9072 }
9073
9074 let reqs = captured
9075 .lock()
9076 .unwrap_or_else(std::sync::PoisonError::into_inner)
9077 .clone();
9078 assert_eq!(reqs.len(), 1);
9079 assert_eq!(reqs[0].method, "dismiss");
9080 });
9081 }
9082
9083 #[test]
9084 fn dispatcher_ui_concurrent_different_methods() {
9085 futures::executor::block_on(async {
9086 let runtime = Rc::new(
9087 PiJsRuntime::with_clock(DeterministicClock::new(0))
9088 .await
9089 .expect("runtime"),
9090 );
9091
9092 runtime
9093 .eval(
9094 r#"
9095 globalThis.results = [];
9096 pi.ui("set_status", { text: "Loading..." })
9097 .then((r) => { globalThis.results.push("status"); });
9098 pi.ui("show_spinner", { message: "Working" })
9099 .then((r) => { globalThis.results.push("spinner"); });
9100 pi.ui("set_widget", { lines: [], content: "w" })
9101 .then((r) => { globalThis.results.push("widget"); });
9102 "#,
9103 )
9104 .await
9105 .expect("eval");
9106
9107 let requests = runtime.drain_hostcall_requests();
9108 assert_eq!(requests.len(), 3);
9109
9110 let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
9111 let ui_handler = Arc::new(TestUiHandler {
9112 captured: Arc::clone(&captured),
9113 response_value: Value::Null,
9114 });
9115
9116 let dispatcher = ExtensionDispatcher::new(
9117 Rc::clone(&runtime),
9118 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
9119 Arc::new(HttpConnector::with_defaults()),
9120 Arc::new(NullSession),
9121 ui_handler,
9122 PathBuf::from("."),
9123 );
9124
9125 for request in requests {
9126 dispatcher.dispatch_and_complete(request).await;
9127 }
9128
9129 while runtime.has_pending() {
9130 runtime.tick().await.expect("tick");
9131 runtime.drain_microtasks().await.expect("microtasks");
9132 }
9133
9134 let reqs = captured
9135 .lock()
9136 .unwrap_or_else(std::sync::PoisonError::into_inner)
9137 .clone();
9138 assert_eq!(reqs.len(), 3);
9139 let methods: Vec<&str> = reqs.iter().map(|r| r.method.as_str()).collect();
9140 assert!(methods.contains(&"set_status"));
9141 assert!(methods.contains(&"show_spinner"));
9142 assert!(methods.contains(&"set_widget"));
9143 });
9144 }
9145
9146 #[test]
9147 fn dispatcher_ui_notification_with_severity() {
9148 futures::executor::block_on(async {
9149 let runtime = Rc::new(
9150 PiJsRuntime::with_clock(DeterministicClock::new(0))
9151 .await
9152 .expect("runtime"),
9153 );
9154
9155 runtime
9156 .eval(
9157 r#"
9158 globalThis.result = null;
9159 pi.ui("notification", { text: "Error occurred", severity: "error", duration: 5000 })
9160 .then((r) => { globalThis.result = r; });
9161 "#,
9162 )
9163 .await
9164 .expect("eval");
9165
9166 let requests = runtime.drain_hostcall_requests();
9167 assert_eq!(requests.len(), 1);
9168
9169 let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
9170 let ui_handler = Arc::new(TestUiHandler {
9171 captured: Arc::clone(&captured),
9172 response_value: Value::Null,
9173 });
9174
9175 let dispatcher = ExtensionDispatcher::new(
9176 Rc::clone(&runtime),
9177 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
9178 Arc::new(HttpConnector::with_defaults()),
9179 Arc::new(NullSession),
9180 ui_handler,
9181 PathBuf::from("."),
9182 );
9183
9184 for request in requests {
9185 dispatcher.dispatch_and_complete(request).await;
9186 }
9187
9188 while runtime.has_pending() {
9189 runtime.tick().await.expect("tick");
9190 runtime.drain_microtasks().await.expect("microtasks");
9191 }
9192
9193 let reqs = captured
9194 .lock()
9195 .unwrap_or_else(std::sync::PoisonError::into_inner)
9196 .clone();
9197 assert_eq!(reqs.len(), 1);
9198 assert_eq!(reqs[0].method, "notification");
9199 assert_eq!(reqs[0].payload["severity"], "error");
9200 assert_eq!(reqs[0].payload["duration"], 5000);
9201 });
9202 }
9203
9204 #[test]
9205 fn dispatcher_ui_widget_with_lines_array() {
9206 futures::executor::block_on(async {
9207 let runtime = Rc::new(
9208 PiJsRuntime::with_clock(DeterministicClock::new(0))
9209 .await
9210 .expect("runtime"),
9211 );
9212
9213 runtime
9214 .eval(
9215 r#"
9216 globalThis.result = null;
9217 pi.ui("set_widget", {
9218 lines: [
9219 { text: "=== Status ===" },
9220 { text: "CPU: 42%" },
9221 { text: "Mem: 8GB" }
9222 ],
9223 content: "Dashboard"
9224 }).then((r) => { globalThis.result = r; });
9225 "#,
9226 )
9227 .await
9228 .expect("eval");
9229
9230 let requests = runtime.drain_hostcall_requests();
9231 assert_eq!(requests.len(), 1);
9232
9233 let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
9234 let ui_handler = Arc::new(TestUiHandler {
9235 captured: Arc::clone(&captured),
9236 response_value: Value::Null,
9237 });
9238
9239 let dispatcher = ExtensionDispatcher::new(
9240 Rc::clone(&runtime),
9241 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
9242 Arc::new(HttpConnector::with_defaults()),
9243 Arc::new(NullSession),
9244 ui_handler,
9245 PathBuf::from("."),
9246 );
9247
9248 for request in requests {
9249 dispatcher.dispatch_and_complete(request).await;
9250 }
9251
9252 while runtime.has_pending() {
9253 runtime.tick().await.expect("tick");
9254 runtime.drain_microtasks().await.expect("microtasks");
9255 }
9256
9257 let reqs = captured
9258 .lock()
9259 .unwrap_or_else(std::sync::PoisonError::into_inner)
9260 .clone();
9261 assert_eq!(reqs.len(), 1);
9262 assert_eq!(reqs[0].method, "set_widget");
9263 let lines = reqs[0].payload["lines"].as_array().unwrap();
9264 assert_eq!(lines.len(), 3);
9265 assert_eq!(lines[0]["text"], "=== Status ===");
9266 assert_eq!(lines[2]["text"], "Mem: 8GB");
9267 });
9268 }
9269
9270 #[test]
9271 fn dispatcher_ui_progress_with_percentage() {
9272 futures::executor::block_on(async {
9273 let runtime = Rc::new(
9274 PiJsRuntime::with_clock(DeterministicClock::new(0))
9275 .await
9276 .expect("runtime"),
9277 );
9278
9279 runtime
9280 .eval(
9281 r#"
9282 globalThis.result = null;
9283 pi.ui("progress", { message: "Uploading", percent: 75, total: 100, current: 75 })
9284 .then((r) => { globalThis.result = r; });
9285 "#,
9286 )
9287 .await
9288 .expect("eval");
9289
9290 let requests = runtime.drain_hostcall_requests();
9291 assert_eq!(requests.len(), 1);
9292
9293 let captured: Arc<Mutex<Vec<ExtensionUiRequest>>> = Arc::new(Mutex::new(Vec::new()));
9294 let ui_handler = Arc::new(TestUiHandler {
9295 captured: Arc::clone(&captured),
9296 response_value: Value::Null,
9297 });
9298
9299 let dispatcher = ExtensionDispatcher::new(
9300 Rc::clone(&runtime),
9301 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
9302 Arc::new(HttpConnector::with_defaults()),
9303 Arc::new(NullSession),
9304 ui_handler,
9305 PathBuf::from("."),
9306 );
9307
9308 for request in requests {
9309 dispatcher.dispatch_and_complete(request).await;
9310 }
9311
9312 while runtime.has_pending() {
9313 runtime.tick().await.expect("tick");
9314 runtime.drain_microtasks().await.expect("microtasks");
9315 }
9316
9317 let reqs = captured
9318 .lock()
9319 .unwrap_or_else(std::sync::PoisonError::into_inner)
9320 .clone();
9321 assert_eq!(reqs.len(), 1);
9322 assert_eq!(reqs[0].method, "progress");
9323 assert_eq!(reqs[0].payload["percent"], 75);
9324 assert_eq!(reqs[0].payload["total"], 100);
9325 assert_eq!(reqs[0].payload["current"], 75);
9326 });
9327 }
9328
9329 #[test]
9332 fn dispatcher_events_emit_name_field_alias() {
9333 futures::executor::block_on(async {
9334 let runtime = Rc::new(
9335 PiJsRuntime::with_clock(DeterministicClock::new(0))
9336 .await
9337 .expect("runtime"),
9338 );
9339
9340 runtime
9342 .eval(
9343 r#"
9344 globalThis.seen = [];
9345 globalThis.emitResult = null;
9346
9347 __pi_begin_extension("ext.listener", { name: "ext.listener" });
9348 pi.on("named_event", (payload, _ctx) => { globalThis.seen.push(payload); });
9349 __pi_end_extension();
9350
9351 __pi_begin_extension("ext.emitter", { name: "ext.emitter" });
9352 pi.events("emit", { name: "named_event", data: { via: "name_field" } })
9353 .then((r) => { globalThis.emitResult = r; });
9354 __pi_end_extension();
9355 "#,
9356 )
9357 .await
9358 .expect("eval");
9359
9360 let requests = runtime.drain_hostcall_requests();
9361 assert_eq!(requests.len(), 1);
9362
9363 let dispatcher = build_dispatcher(Rc::clone(&runtime));
9364 for request in requests {
9365 dispatcher.dispatch_and_complete(request).await;
9366 }
9367
9368 runtime.tick().await.expect("tick");
9369
9370 runtime
9371 .eval(
9372 r#"
9373 if (!globalThis.emitResult) throw new Error("emit not resolved");
9374 if (globalThis.emitResult.dispatched !== true) {
9375 throw new Error("emit not dispatched: " + JSON.stringify(globalThis.emitResult));
9376 }
9377 if (globalThis.seen.length !== 1) {
9378 throw new Error("Expected 1 handler call, got: " + globalThis.seen.length);
9379 }
9380 if (globalThis.seen[0].via !== "name_field") {
9381 throw new Error("Wrong payload: " + JSON.stringify(globalThis.seen[0]));
9382 }
9383 "#,
9384 )
9385 .await
9386 .expect("verify name field alias");
9387 });
9388 }
9389
9390 #[test]
9391 fn dispatcher_events_unsupported_op_rejects() {
9392 futures::executor::block_on(async {
9393 let runtime = Rc::new(
9394 PiJsRuntime::with_clock(DeterministicClock::new(0))
9395 .await
9396 .expect("runtime"),
9397 );
9398
9399 runtime
9400 .eval(
9401 r#"
9402 globalThis.errMsg = "";
9403 pi.events("nonexistent_op", {})
9404 .then(() => { globalThis.errMsg = "should_not_resolve"; })
9405 .catch((e) => { globalThis.errMsg = e.message || String(e); });
9406 "#,
9407 )
9408 .await
9409 .expect("eval");
9410
9411 let requests = runtime.drain_hostcall_requests();
9412 assert_eq!(requests.len(), 1);
9413
9414 let dispatcher = build_dispatcher(Rc::clone(&runtime));
9415 for request in requests {
9416 dispatcher.dispatch_and_complete(request).await;
9417 }
9418
9419 while runtime.has_pending() {
9420 runtime.tick().await.expect("tick");
9421 runtime.drain_microtasks().await.expect("microtasks");
9422 }
9423
9424 runtime
9425 .eval(
9426 r#"
9427 if (globalThis.errMsg === "should_not_resolve") {
9428 throw new Error("Expected rejection for unsupported events op");
9429 }
9430 if (!globalThis.errMsg.toLowerCase().includes("unsupported")) {
9431 throw new Error("Expected 'unsupported' error, got: " + globalThis.errMsg);
9432 }
9433 "#,
9434 )
9435 .await
9436 .expect("verify unsupported op rejection");
9437 });
9438 }
9439
9440 #[test]
9441 fn dispatcher_events_emit_empty_event_name_rejects() {
9442 futures::executor::block_on(async {
9443 let runtime = Rc::new(
9444 PiJsRuntime::with_clock(DeterministicClock::new(0))
9445 .await
9446 .expect("runtime"),
9447 );
9448
9449 runtime
9450 .eval(
9451 r#"
9452 globalThis.errMsg = "";
9453 pi.events("emit", { event: "" })
9454 .then(() => { globalThis.errMsg = "should_not_resolve"; })
9455 .catch((e) => { globalThis.errMsg = e.message || String(e); });
9456 "#,
9457 )
9458 .await
9459 .expect("eval");
9460
9461 let requests = runtime.drain_hostcall_requests();
9462 assert_eq!(requests.len(), 1);
9463
9464 let dispatcher = build_dispatcher(Rc::clone(&runtime));
9465 for request in requests {
9466 dispatcher.dispatch_and_complete(request).await;
9467 }
9468
9469 while runtime.has_pending() {
9470 runtime.tick().await.expect("tick");
9471 runtime.drain_microtasks().await.expect("microtasks");
9472 }
9473
9474 runtime
9475 .eval(
9476 r#"
9477 if (globalThis.errMsg === "should_not_resolve") {
9478 throw new Error("Expected rejection for empty event name");
9479 }
9480 if (!globalThis.errMsg.includes("event") && !globalThis.errMsg.includes("non-empty")) {
9481 throw new Error("Expected event name error, got: " + globalThis.errMsg);
9482 }
9483 "#,
9484 )
9485 .await
9486 .expect("verify empty event name rejection");
9487 });
9488 }
9489
9490 #[test]
9491 fn dispatcher_events_emit_handler_count_in_response() {
9492 futures::executor::block_on(async {
9493 let runtime = Rc::new(
9494 PiJsRuntime::with_clock(DeterministicClock::new(0))
9495 .await
9496 .expect("runtime"),
9497 );
9498
9499 runtime
9501 .eval(
9502 r#"
9503 globalThis.emitResult = null;
9504
9505 __pi_begin_extension("ext.h1", { name: "ext.h1" });
9506 pi.on("counted_event", (_p, _c) => {});
9507 __pi_end_extension();
9508
9509 __pi_begin_extension("ext.h2", { name: "ext.h2" });
9510 pi.on("counted_event", (_p, _c) => {});
9511 __pi_end_extension();
9512
9513 __pi_begin_extension("ext.emitter", { name: "ext.emitter" });
9514 pi.events("emit", { event: "counted_event", data: {} })
9515 .then((r) => { globalThis.emitResult = r; });
9516 __pi_end_extension();
9517 "#,
9518 )
9519 .await
9520 .expect("eval");
9521
9522 let requests = runtime.drain_hostcall_requests();
9523 assert_eq!(requests.len(), 1);
9524
9525 let dispatcher = build_dispatcher(Rc::clone(&runtime));
9526 for request in requests {
9527 dispatcher.dispatch_and_complete(request).await;
9528 }
9529
9530 runtime.tick().await.expect("tick");
9531
9532 runtime
9533 .eval(
9534 r#"
9535 if (!globalThis.emitResult) throw new Error("emit not resolved");
9536 if (globalThis.emitResult.dispatched !== true) {
9537 throw new Error("emit not dispatched: " + JSON.stringify(globalThis.emitResult));
9538 }
9539 if (typeof globalThis.emitResult.handler_count !== "number") {
9540 throw new Error("Expected handler_count number, got: " + JSON.stringify(globalThis.emitResult));
9541 }
9542 if (globalThis.emitResult.handler_count < 2) {
9543 throw new Error("Expected at least 2 handlers, got: " + globalThis.emitResult.handler_count);
9544 }
9545 "#,
9546 )
9547 .await
9548 .expect("verify handler count");
9549 });
9550 }
9551
9552 #[test]
9553 fn dispatcher_events_list_returns_registered_event_names() {
9554 futures::executor::block_on(async {
9555 let runtime = Rc::new(
9556 PiJsRuntime::with_clock(DeterministicClock::new(0))
9557 .await
9558 .expect("runtime"),
9559 );
9560
9561 runtime
9563 .eval(
9564 r#"
9565 globalThis.result = null;
9566
9567 __pi_begin_extension("ext.multi", { name: "ext.multi" });
9568 pi.on("event_alpha", (_p, _c) => {});
9569 pi.on("event_beta", (_p, _c) => {});
9570 pi.on("event_gamma", (_p, _c) => {});
9571 pi.events("list", {})
9572 .then((r) => { globalThis.result = r; })
9573 .catch((e) => { globalThis.result = { error: e.message || String(e) }; });
9574 __pi_end_extension();
9575 "#,
9576 )
9577 .await
9578 .expect("eval");
9579
9580 let requests = runtime.drain_hostcall_requests();
9581 assert_eq!(requests.len(), 1);
9582
9583 let dispatcher = build_dispatcher(Rc::clone(&runtime));
9584 for request in requests {
9585 dispatcher.dispatch_and_complete(request).await;
9586 }
9587
9588 while runtime.has_pending() {
9589 runtime.tick().await.expect("tick");
9590 runtime.drain_microtasks().await.expect("microtasks");
9591 }
9592
9593 runtime
9594 .eval(
9595 r#"
9596 if (!globalThis.result) throw new Error("list not resolved");
9597 if (globalThis.result.error) throw new Error("list error: " + globalThis.result.error);
9598 const events = globalThis.result.events;
9599 if (!Array.isArray(events)) {
9600 throw new Error("Expected events array, got: " + JSON.stringify(globalThis.result));
9601 }
9602 if (events.length < 3) {
9603 throw new Error("Expected at least 3 events, got: " + JSON.stringify(events));
9604 }
9605 if (!events.includes("event_alpha")) {
9606 throw new Error("Missing event_alpha in: " + JSON.stringify(events));
9607 }
9608 if (!events.includes("event_beta")) {
9609 throw new Error("Missing event_beta in: " + JSON.stringify(events));
9610 }
9611 "#,
9612 )
9613 .await
9614 .expect("verify event names list");
9615 });
9616 }
9617
9618 #[test]
9619 fn dispatcher_events_emit_no_handlers_still_resolves() {
9620 futures::executor::block_on(async {
9621 let runtime = Rc::new(
9622 PiJsRuntime::with_clock(DeterministicClock::new(0))
9623 .await
9624 .expect("runtime"),
9625 );
9626
9627 runtime
9629 .eval(
9630 r#"
9631 globalThis.emitResult = null;
9632
9633 __pi_begin_extension("ext.lonely", { name: "ext.lonely" });
9634 pi.events("emit", { event: "unheard_event", data: { msg: "nobody listens" } })
9635 .then((r) => { globalThis.emitResult = r; })
9636 .catch((e) => { globalThis.emitResult = { error: e.message || String(e) }; });
9637 __pi_end_extension();
9638 "#,
9639 )
9640 .await
9641 .expect("eval");
9642
9643 let requests = runtime.drain_hostcall_requests();
9644 assert_eq!(requests.len(), 1);
9645
9646 let dispatcher = build_dispatcher(Rc::clone(&runtime));
9647 for request in requests {
9648 dispatcher.dispatch_and_complete(request).await;
9649 }
9650
9651 while runtime.has_pending() {
9652 runtime.tick().await.expect("tick");
9653 runtime.drain_microtasks().await.expect("microtasks");
9654 }
9655
9656 runtime
9657 .eval(
9658 r#"
9659 if (!globalThis.emitResult) throw new Error("emit not resolved");
9660 // Should resolve even with no handlers (dispatched: true, handler_count: 0)
9661 if (globalThis.emitResult.error) {
9662 throw new Error("emit errored: " + globalThis.emitResult.error);
9663 }
9664 if (globalThis.emitResult.dispatched !== true) {
9665 throw new Error("emit not dispatched: " + JSON.stringify(globalThis.emitResult));
9666 }
9667 "#,
9668 )
9669 .await
9670 .expect("verify emit with no handlers");
9671 });
9672 }
9673
9674 #[test]
9677 fn dispatcher_tool_read_returns_file_content() {
9678 futures::executor::block_on(async {
9679 let temp_dir = tempfile::tempdir().expect("tempdir");
9680 let file_path = temp_dir.path().join("readable.txt");
9681 std::fs::write(&file_path, "file content here").expect("write test file");
9682
9683 let runtime = Rc::new(
9684 PiJsRuntime::with_clock(DeterministicClock::new(0))
9685 .await
9686 .expect("runtime"),
9687 );
9688
9689 let file_path_js = file_path.display().to_string().replace('\\', "\\\\");
9690 let script = format!(
9691 r#"
9692 globalThis.result = null;
9693 pi.tool("read", {{ path: "{file_path_js}" }})
9694 .then((r) => {{ globalThis.result = r; }})
9695 .catch((e) => {{ globalThis.result = {{ error: e.message || String(e) }}; }});
9696 "#
9697 );
9698 runtime.eval(&script).await.expect("eval");
9699
9700 let requests = runtime.drain_hostcall_requests();
9701 assert_eq!(requests.len(), 1);
9702
9703 let dispatcher = ExtensionDispatcher::new(
9704 Rc::clone(&runtime),
9705 Arc::new(ToolRegistry::new(&["read"], temp_dir.path(), None)),
9706 Arc::new(HttpConnector::with_defaults()),
9707 Arc::new(NullSession),
9708 Arc::new(NullUiHandler),
9709 temp_dir.path().to_path_buf(),
9710 );
9711
9712 for request in requests {
9713 dispatcher.dispatch_and_complete(request).await;
9714 }
9715
9716 while runtime.has_pending() {
9717 runtime.tick().await.expect("tick");
9718 runtime.drain_microtasks().await.expect("microtasks");
9719 }
9720
9721 runtime
9722 .eval(
9723 r#"
9724 if (!globalThis.result) throw new Error("read not resolved");
9725 if (globalThis.result.error) throw new Error("read error: " + globalThis.result.error);
9726 "#,
9727 )
9728 .await
9729 .expect("verify read tool");
9730 });
9731 }
9732
9733 #[test]
9742 fn session_dispatch_taxonomy_unknown_op_is_invalid_request() {
9743 futures::executor::block_on(async {
9744 let runtime = Rc::new(
9745 PiJsRuntime::with_clock(DeterministicClock::new(0))
9746 .await
9747 .expect("runtime"),
9748 );
9749 let dispatcher = build_dispatcher(Rc::clone(&runtime));
9750 let outcome = dispatcher
9751 .dispatch_session("c1", "nonexistent_op", serde_json::json!({}))
9752 .await;
9753 match outcome {
9754 HostcallOutcome::Error { code, .. } => {
9755 assert_eq!(
9756 code, "invalid_request",
9757 "unknown op must be invalid_request"
9758 );
9759 }
9760 HostcallOutcome::Success(_) | HostcallOutcome::StreamChunk { .. } => {
9761 panic!();
9762 }
9763 }
9764 });
9765 }
9766
9767 #[test]
9768 fn session_dispatch_taxonomy_set_model_missing_provider_is_invalid_request() {
9769 futures::executor::block_on(async {
9770 let runtime = Rc::new(
9771 PiJsRuntime::with_clock(DeterministicClock::new(0))
9772 .await
9773 .expect("runtime"),
9774 );
9775 let dispatcher = build_dispatcher(Rc::clone(&runtime));
9776 let outcome = dispatcher
9777 .dispatch_session("c2", "set_model", serde_json::json!({"modelId": "gpt-4o"}))
9778 .await;
9779 match outcome {
9780 HostcallOutcome::Error { code, .. } => {
9781 assert_eq!(
9782 code, "invalid_request",
9783 "set_model missing provider must be invalid_request"
9784 );
9785 }
9786 HostcallOutcome::Success(_) => {
9787 panic!();
9788 }
9789 HostcallOutcome::StreamChunk { .. } => {
9790 panic!();
9791 }
9792 }
9793 });
9794 }
9795
9796 #[test]
9797 fn session_dispatch_taxonomy_set_model_missing_model_id_is_invalid_request() {
9798 futures::executor::block_on(async {
9799 let runtime = Rc::new(
9800 PiJsRuntime::with_clock(DeterministicClock::new(0))
9801 .await
9802 .expect("runtime"),
9803 );
9804 let dispatcher = build_dispatcher(Rc::clone(&runtime));
9805 let outcome = dispatcher
9806 .dispatch_session(
9807 "c3",
9808 "set_model",
9809 serde_json::json!({"provider": "anthropic"}),
9810 )
9811 .await;
9812 match outcome {
9813 HostcallOutcome::Error { code, .. } => {
9814 assert_eq!(code, "invalid_request");
9815 }
9816 HostcallOutcome::Success(_) => {
9817 panic!();
9818 }
9819 HostcallOutcome::StreamChunk { .. } => {
9820 panic!();
9821 }
9822 }
9823 });
9824 }
9825
9826 #[test]
9827 fn session_dispatch_taxonomy_set_thinking_level_empty_is_invalid_request() {
9828 futures::executor::block_on(async {
9829 let runtime = Rc::new(
9830 PiJsRuntime::with_clock(DeterministicClock::new(0))
9831 .await
9832 .expect("runtime"),
9833 );
9834 let dispatcher = build_dispatcher(Rc::clone(&runtime));
9835 let outcome = dispatcher
9836 .dispatch_session("c4", "set_thinking_level", serde_json::json!({}))
9837 .await;
9838 match outcome {
9839 HostcallOutcome::Error { code, .. } => {
9840 assert_eq!(code, "invalid_request");
9841 }
9842 HostcallOutcome::Success(_) => {
9843 panic!();
9844 }
9845 HostcallOutcome::StreamChunk { .. } => {
9846 panic!();
9847 }
9848 }
9849 });
9850 }
9851
9852 #[test]
9853 fn session_dispatch_taxonomy_set_label_empty_target_is_invalid_request() {
9854 futures::executor::block_on(async {
9855 let runtime = Rc::new(
9856 PiJsRuntime::with_clock(DeterministicClock::new(0))
9857 .await
9858 .expect("runtime"),
9859 );
9860 let dispatcher = build_dispatcher(Rc::clone(&runtime));
9861 let outcome = dispatcher
9862 .dispatch_session("c5", "set_label", serde_json::json!({}))
9863 .await;
9864 match outcome {
9865 HostcallOutcome::Error { code, .. } => {
9866 assert_eq!(code, "invalid_request");
9867 }
9868 HostcallOutcome::Success(_) => {
9869 panic!();
9870 }
9871 HostcallOutcome::StreamChunk { .. } => {
9872 panic!();
9873 }
9874 }
9875 });
9876 }
9877
9878 #[test]
9879 fn session_dispatch_taxonomy_append_message_invalid_is_invalid_request() {
9880 futures::executor::block_on(async {
9881 let runtime = Rc::new(
9882 PiJsRuntime::with_clock(DeterministicClock::new(0))
9883 .await
9884 .expect("runtime"),
9885 );
9886 let dispatcher = build_dispatcher(Rc::clone(&runtime));
9887 let outcome = dispatcher
9888 .dispatch_session(
9889 "c6",
9890 "append_message",
9891 serde_json::json!({"message": {"not_a_valid_message": true}}),
9892 )
9893 .await;
9894 match outcome {
9895 HostcallOutcome::Error { code, .. } => {
9896 assert_eq!(
9897 code, "invalid_request",
9898 "malformed message must be invalid_request"
9899 );
9900 }
9901 HostcallOutcome::Success(_) => {
9902 panic!();
9903 }
9904 HostcallOutcome::StreamChunk { .. } => {
9905 panic!();
9906 }
9907 }
9908 });
9909 }
9910
9911 #[test]
9912 #[allow(clippy::items_after_statements, clippy::too_many_lines)]
9913 fn session_dispatch_taxonomy_io_error_from_session_trait() {
9914 futures::executor::block_on(async {
9915 let runtime = Rc::new(
9916 PiJsRuntime::with_clock(DeterministicClock::new(0))
9917 .await
9918 .expect("runtime"),
9919 );
9920
9921 struct FailSession;
9923
9924 #[async_trait]
9925 impl ExtensionSession for FailSession {
9926 async fn get_state(&self) -> Value {
9927 Value::Null
9928 }
9929 async fn get_messages(&self) -> Vec<SessionMessage> {
9930 Vec::new()
9931 }
9932 async fn get_entries(&self) -> Vec<Value> {
9933 Vec::new()
9934 }
9935 async fn get_branch(&self) -> Vec<Value> {
9936 Vec::new()
9937 }
9938 async fn set_name(&self, _name: String) -> Result<()> {
9939 Err(crate::error::Error::from(std::io::Error::other(
9940 "disk full",
9941 )))
9942 }
9943 async fn append_message(&self, _message: SessionMessage) -> Result<()> {
9944 Err(crate::error::Error::from(std::io::Error::other(
9945 "disk full",
9946 )))
9947 }
9948 async fn append_custom_entry(
9949 &self,
9950 _custom_type: String,
9951 _data: Option<Value>,
9952 ) -> Result<()> {
9953 Err(crate::error::Error::from(std::io::Error::other(
9954 "disk full",
9955 )))
9956 }
9957 async fn set_model(&self, _provider: String, _model_id: String) -> Result<()> {
9958 Err(crate::error::Error::from(std::io::Error::other(
9959 "disk full",
9960 )))
9961 }
9962 async fn get_model(&self) -> (Option<String>, Option<String>) {
9963 (None, None)
9964 }
9965 async fn set_thinking_level(&self, _level: String) -> Result<()> {
9966 Err(crate::error::Error::from(std::io::Error::other(
9967 "disk full",
9968 )))
9969 }
9970 async fn get_thinking_level(&self) -> Option<String> {
9971 None
9972 }
9973 async fn set_label(
9974 &self,
9975 _target_id: String,
9976 _label: Option<String>,
9977 ) -> Result<()> {
9978 Err(crate::error::Error::from(std::io::Error::other(
9979 "disk full",
9980 )))
9981 }
9982 }
9983
9984 let dispatcher = ExtensionDispatcher::new(
9985 Rc::clone(&runtime),
9986 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
9987 Arc::new(HttpConnector::with_defaults()),
9988 Arc::new(FailSession),
9989 Arc::new(NullUiHandler),
9990 PathBuf::from("."),
9991 );
9992
9993 let io_cases = [
9995 ("set_name", serde_json::json!({"name": "test"})),
9996 (
9997 "set_model",
9998 serde_json::json!({"provider": "a", "modelId": "b"}),
9999 ),
10000 ("set_thinking_level", serde_json::json!({"level": "high"})),
10001 (
10002 "set_label",
10003 serde_json::json!({"targetId": "abc", "label": "x"}),
10004 ),
10005 (
10006 "append_entry",
10007 serde_json::json!({"customType": "note", "data": null}),
10008 ),
10009 (
10010 "append_message",
10011 serde_json::json!({"message": {"role": "custom", "customType": "x", "content": "y", "display": true}}),
10012 ),
10013 ];
10014
10015 for (op, params) in &io_cases {
10016 let outcome = dispatcher.dispatch_session("cx", op, params.clone()).await;
10017 match outcome {
10018 HostcallOutcome::Error { code, .. } => {
10019 assert_eq!(code, "io", "session IO error for op '{op}' must be 'io'");
10020 }
10021 HostcallOutcome::Success(_) => {
10022 panic!();
10023 }
10024 HostcallOutcome::StreamChunk { .. } => {
10025 panic!();
10026 }
10027 }
10028 }
10029 });
10030 }
10031
10032 #[test]
10033 fn session_dispatch_taxonomy_read_ops_succeed_with_null_session() {
10034 futures::executor::block_on(async {
10035 let runtime = Rc::new(
10036 PiJsRuntime::with_clock(DeterministicClock::new(0))
10037 .await
10038 .expect("runtime"),
10039 );
10040 let dispatcher = build_dispatcher(Rc::clone(&runtime));
10041
10042 let read_ops = [
10043 "get_state",
10044 "getState",
10045 "get_messages",
10046 "getMessages",
10047 "get_entries",
10048 "getEntries",
10049 "get_branch",
10050 "getBranch",
10051 "get_file",
10052 "getFile",
10053 "get_name",
10054 "getName",
10055 "get_model",
10056 "getModel",
10057 "get_thinking_level",
10058 "getThinkingLevel",
10059 ];
10060
10061 for op in &read_ops {
10062 let outcome = dispatcher
10063 .dispatch_session("cr", op, serde_json::json!({}))
10064 .await;
10065 assert!(
10066 matches!(outcome, HostcallOutcome::Success(_)),
10067 "read op '{op}' should succeed"
10068 );
10069 }
10070 });
10071 }
10072
10073 #[test]
10074 fn session_dispatch_taxonomy_case_insensitive_aliases() {
10075 futures::executor::block_on(async {
10076 let runtime = Rc::new(
10077 PiJsRuntime::with_clock(DeterministicClock::new(0))
10078 .await
10079 .expect("runtime"),
10080 );
10081 let dispatcher = build_dispatcher(Rc::clone(&runtime));
10082
10083 let alias_pairs = [
10085 ("get_state", "getstate"),
10086 ("get_messages", "getmessages"),
10087 ("get_entries", "getentries"),
10088 ("get_branch", "getbranch"),
10089 ("get_file", "getfile"),
10090 ("get_name", "getname"),
10091 ("get_model", "getmodel"),
10092 ("get_thinking_level", "getthinkinglevel"),
10093 ];
10094
10095 for (snake, camel) in &alias_pairs {
10096 let outcome_a = dispatcher
10097 .dispatch_session("ca", snake, serde_json::json!({}))
10098 .await;
10099 let outcome_b = dispatcher
10100 .dispatch_session("cb", camel, serde_json::json!({}))
10101 .await;
10102 match (&outcome_a, &outcome_b) {
10103 (HostcallOutcome::Success(a), HostcallOutcome::Success(b)) => {
10104 assert_eq!(
10105 a, b,
10106 "alias pair ({snake}, {camel}) should produce same output"
10107 );
10108 }
10109 _ => panic!(),
10110 }
10111 }
10112 });
10113 }
10114
10115 #[test]
10116 fn ui_dispatch_taxonomy_missing_op_is_invalid_request() {
10117 futures::executor::block_on(async {
10118 let runtime = Rc::new(
10119 PiJsRuntime::with_clock(DeterministicClock::new(0))
10120 .await
10121 .expect("runtime"),
10122 );
10123 let dispatcher = build_dispatcher(Rc::clone(&runtime));
10124 let outcome = dispatcher
10125 .dispatch_ui("ui-1", " ", serde_json::json!({}), None)
10126 .await;
10127 assert!(
10128 matches!(outcome, HostcallOutcome::Error { code, .. } if code == "invalid_request")
10129 );
10130 });
10131 }
10132
10133 #[test]
10134 fn ui_dispatch_taxonomy_timeout_error_maps_to_timeout() {
10135 futures::executor::block_on(async {
10136 struct TimeoutUiHandler;
10137
10138 #[async_trait]
10139 impl ExtensionUiHandler for TimeoutUiHandler {
10140 async fn request_ui(
10141 &self,
10142 _request: ExtensionUiRequest,
10143 ) -> Result<Option<ExtensionUiResponse>> {
10144 Err(Error::extension("Extension UI request timed out"))
10145 }
10146 }
10147
10148 let runtime = Rc::new(
10149 PiJsRuntime::with_clock(DeterministicClock::new(0))
10150 .await
10151 .expect("runtime"),
10152 );
10153 let dispatcher = ExtensionDispatcher::new(
10154 Rc::clone(&runtime),
10155 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
10156 Arc::new(HttpConnector::with_defaults()),
10157 Arc::new(NullSession),
10158 Arc::new(TimeoutUiHandler),
10159 PathBuf::from("."),
10160 );
10161
10162 let outcome = dispatcher
10163 .dispatch_ui("ui-2", "confirm", serde_json::json!({}), None)
10164 .await;
10165 assert!(matches!(outcome, HostcallOutcome::Error { code, .. } if code == "timeout"));
10166 });
10167 }
10168
10169 #[test]
10170 fn ui_dispatch_taxonomy_unconfigured_maps_to_denied() {
10171 futures::executor::block_on(async {
10172 struct MissingUiHandler;
10173
10174 #[async_trait]
10175 impl ExtensionUiHandler for MissingUiHandler {
10176 async fn request_ui(
10177 &self,
10178 _request: ExtensionUiRequest,
10179 ) -> Result<Option<ExtensionUiResponse>> {
10180 Err(Error::extension("Extension UI sender not configured"))
10181 }
10182 }
10183
10184 let runtime = Rc::new(
10185 PiJsRuntime::with_clock(DeterministicClock::new(0))
10186 .await
10187 .expect("runtime"),
10188 );
10189 let dispatcher = ExtensionDispatcher::new(
10190 Rc::clone(&runtime),
10191 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
10192 Arc::new(HttpConnector::with_defaults()),
10193 Arc::new(NullSession),
10194 Arc::new(MissingUiHandler),
10195 PathBuf::from("."),
10196 );
10197
10198 let outcome = dispatcher
10199 .dispatch_ui("ui-3", "confirm", serde_json::json!({}), None)
10200 .await;
10201 assert!(matches!(outcome, HostcallOutcome::Error { code, .. } if code == "denied"));
10202 });
10203 }
10204
10205 #[test]
10206 fn protocol_adapter_host_call_to_host_result_success() {
10207 futures::executor::block_on(async {
10208 let runtime = Rc::new(
10209 PiJsRuntime::with_clock(DeterministicClock::new(0))
10210 .await
10211 .expect("runtime"),
10212 );
10213 let dispatcher = build_dispatcher(Rc::clone(&runtime));
10214 let message = ExtensionMessage {
10215 id: "msg-hostcall-1".to_string(),
10216 version: PROTOCOL_VERSION.to_string(),
10217 body: ExtensionBody::HostCall(HostCallPayload {
10218 call_id: "call-hostcall-1".to_string(),
10219 capability: "session".to_string(),
10220 method: "session".to_string(),
10221 params: serde_json::json!({ "op": "get_state" }),
10222 timeout_ms: None,
10223 cancel_token: None,
10224 context: None,
10225 }),
10226 };
10227
10228 let response = dispatcher
10229 .dispatch_protocol_message(message)
10230 .await
10231 .expect("protocol dispatch");
10232
10233 match response.body {
10234 ExtensionBody::HostResult(result) => {
10235 assert_eq!(result.call_id, "call-hostcall-1");
10236 assert!(!result.is_error, "expected success host_result");
10237 assert!(
10238 result.output.is_object(),
10239 "host_result output must remain object"
10240 );
10241 assert!(result.error.is_none(), "success should not include error");
10242 }
10243 other => panic!(),
10244 }
10245 });
10246 }
10247
10248 #[test]
10249 fn protocol_adapter_missing_op_returns_invalid_request_taxonomy() {
10250 futures::executor::block_on(async {
10251 let runtime = Rc::new(
10252 PiJsRuntime::with_clock(DeterministicClock::new(0))
10253 .await
10254 .expect("runtime"),
10255 );
10256 let dispatcher = build_dispatcher(Rc::clone(&runtime));
10257 let message = ExtensionMessage {
10258 id: "msg-hostcall-2".to_string(),
10259 version: PROTOCOL_VERSION.to_string(),
10260 body: ExtensionBody::HostCall(HostCallPayload {
10261 call_id: "call-hostcall-2".to_string(),
10262 capability: "session".to_string(),
10263 method: "session".to_string(),
10264 params: serde_json::json!({}),
10265 timeout_ms: None,
10266 cancel_token: None,
10267 context: None,
10268 }),
10269 };
10270
10271 let response = dispatcher
10272 .dispatch_protocol_message(message)
10273 .await
10274 .expect("protocol dispatch");
10275
10276 match response.body {
10277 ExtensionBody::HostResult(result) => {
10278 assert!(result.is_error, "expected error host_result");
10279 assert!(result.output.is_object(), "error output must be object");
10280 let error = result.error.expect("error payload");
10281 assert_eq!(
10282 error.code,
10283 crate::extensions::HostCallErrorCode::InvalidRequest
10284 );
10285 let details = error.details.expect("error details");
10286 assert_eq!(
10287 details["dispatcherDecisionTrace"]["selectedRuntime"],
10288 Value::String("rust-extension-dispatcher".to_string())
10289 );
10290 assert_eq!(
10291 details["dispatcherDecisionTrace"]["schemaPath"],
10292 Value::String("ExtensionBody::HostCall/HostCallPayload".to_string())
10293 );
10294 assert_eq!(
10295 details["dispatcherDecisionTrace"]["schemaVersion"],
10296 Value::String(PROTOCOL_VERSION.to_string())
10297 );
10298 assert_eq!(
10299 details["dispatcherDecisionTrace"]["fallbackReason"],
10300 Value::String("schema_validation_failed".to_string())
10301 );
10302 assert_eq!(
10303 details["extensionInput"]["method"],
10304 Value::String("session".to_string())
10305 );
10306 assert_eq!(
10307 details["extensionOutput"]["code"],
10308 Value::String("invalid_request".to_string())
10309 );
10310 }
10311 other => panic!(),
10312 }
10313 });
10314 }
10315
10316 #[test]
10317 fn protocol_adapter_unknown_method_includes_fallback_trace() {
10318 futures::executor::block_on(async {
10319 let runtime = Rc::new(
10320 PiJsRuntime::with_clock(DeterministicClock::new(0))
10321 .await
10322 .expect("runtime"),
10323 );
10324 let dispatcher = build_dispatcher(Rc::clone(&runtime));
10325 let message = ExtensionMessage {
10326 id: "msg-hostcall-unknown-method".to_string(),
10327 version: PROTOCOL_VERSION.to_string(),
10328 body: ExtensionBody::HostCall(HostCallPayload {
10329 call_id: "call-hostcall-unknown-method".to_string(),
10330 capability: "session".to_string(),
10331 method: "not_a_real_method".to_string(),
10332 params: serde_json::json!({ "foo": 1 }),
10333 timeout_ms: None,
10334 cancel_token: None,
10335 context: None,
10336 }),
10337 };
10338
10339 let response = dispatcher
10340 .dispatch_protocol_message(message)
10341 .await
10342 .expect("protocol dispatch");
10343
10344 match response.body {
10345 ExtensionBody::HostResult(result) => {
10346 assert!(result.is_error, "expected error host_result");
10347 let error = result.error.expect("error payload");
10348 assert_eq!(
10349 error.code,
10350 crate::extensions::HostCallErrorCode::InvalidRequest
10351 );
10352 let details = error.details.expect("error details");
10353 assert_eq!(
10354 details["dispatcherDecisionTrace"]["fallbackReason"],
10355 Value::String("unsupported_method_fallback".to_string())
10356 );
10357 assert_eq!(
10358 details["dispatcherDecisionTrace"]["method"],
10359 Value::String("not_a_real_method".to_string())
10360 );
10361 assert_eq!(
10362 details["schemaDiff"]["observedParamKeys"],
10363 Value::Array(vec![Value::String("foo".to_string())])
10364 );
10365 assert_eq!(
10366 details["extensionInput"]["params"]["foo"],
10367 Value::Number(serde_json::Number::from(1))
10368 );
10369 }
10370 other => panic!(),
10371 }
10372 });
10373 }
10374
10375 #[test]
10376 fn dispatch_events_list_unknown_extension_returns_empty_events() {
10377 futures::executor::block_on(async {
10378 let runtime = Rc::new(
10379 PiJsRuntime::with_clock(DeterministicClock::new(0))
10380 .await
10381 .expect("runtime"),
10382 );
10383 let dispatcher = build_dispatcher(Rc::clone(&runtime));
10384
10385 let outcome = dispatcher
10386 .dispatch_events(
10387 "call-events-unknown-extension",
10388 Some("missing.extension"),
10389 "list",
10390 serde_json::json!({}),
10391 )
10392 .await;
10393
10394 match outcome {
10395 HostcallOutcome::Success(value) => {
10396 assert_eq!(value, serde_json::json!({ "events": [] }));
10397 }
10398 HostcallOutcome::Error { code, message } => {
10399 panic!();
10400 }
10401 HostcallOutcome::StreamChunk { .. } => {
10402 panic!();
10403 }
10404 }
10405 });
10406 }
10407
10408 #[test]
10409 fn protocol_adapter_rejects_non_host_call_messages() {
10410 futures::executor::block_on(async {
10411 let runtime = Rc::new(
10412 PiJsRuntime::with_clock(DeterministicClock::new(0))
10413 .await
10414 .expect("runtime"),
10415 );
10416 let dispatcher = build_dispatcher(Rc::clone(&runtime));
10417 let message = ExtensionMessage {
10418 id: "msg-hostcall-3".to_string(),
10419 version: PROTOCOL_VERSION.to_string(),
10420 body: ExtensionBody::ToolResult(crate::extensions::ToolResultPayload {
10421 call_id: "tool-1".to_string(),
10422 output: serde_json::json!({}),
10423 is_error: false,
10424 }),
10425 };
10426
10427 let err = dispatcher
10428 .dispatch_protocol_message(message)
10429 .await
10430 .expect_err("non-host-call should fail");
10431 assert!(
10432 err.to_string()
10433 .contains("dispatch_protocol_message expects host_call"),
10434 "unexpected error: {err}"
10435 );
10436 });
10437 }
10438
10439 #[test]
10444 fn dispatch_denied_capability_returns_error() {
10445 futures::executor::block_on(async {
10446 let runtime = Rc::new(
10447 PiJsRuntime::with_clock(DeterministicClock::new(0))
10448 .await
10449 .expect("runtime"),
10450 );
10451
10452 runtime
10454 .eval(
10455 r#"
10456 globalThis.err = null;
10457 pi.exec("echo", ["hello"]).catch((e) => { globalThis.err = e; });
10458 "#,
10459 )
10460 .await
10461 .expect("eval");
10462
10463 let requests = runtime.drain_hostcall_requests();
10464 assert_eq!(requests.len(), 1);
10465
10466 let policy = ExtensionPolicy::from_profile(PolicyProfile::Safe);
10468 let dispatcher = build_dispatcher_with_policy(Rc::clone(&runtime), policy);
10469
10470 for request in requests {
10471 dispatcher.dispatch_and_complete(request).await;
10472 }
10473
10474 let _ = runtime.tick().await.expect("tick");
10475
10476 runtime
10477 .eval(
10478 r#"
10479 if (globalThis.err === null) throw new Error("Promise not rejected");
10480 if (globalThis.err.code !== "denied") {
10481 throw new Error("Expected denied code, got: " + globalThis.err.code);
10482 }
10483 "#,
10484 )
10485 .await
10486 .expect("verify denied error");
10487 });
10488 }
10489
10490 #[test]
10491 fn dispatch_denied_capability_still_denied_when_advanced_path_disabled() {
10492 futures::executor::block_on(async {
10493 let runtime = Rc::new(
10494 PiJsRuntime::with_clock(DeterministicClock::new(0))
10495 .await
10496 .expect("runtime"),
10497 );
10498
10499 runtime
10500 .eval(
10501 r#"
10502 globalThis.err = null;
10503 pi.exec("echo", ["hello"]).catch((e) => { globalThis.err = e; });
10504 "#,
10505 )
10506 .await
10507 .expect("eval");
10508
10509 let requests = runtime.drain_hostcall_requests();
10510 assert_eq!(requests.len(), 1);
10511
10512 let oracle_config = DualExecOracleConfig {
10513 sample_ppm: 0,
10514 ..DualExecOracleConfig::default()
10515 };
10516 let policy = ExtensionPolicy::from_profile(PolicyProfile::Safe);
10517 let mut dispatcher =
10518 build_dispatcher_with_policy_and_oracle(Rc::clone(&runtime), policy, oracle_config);
10519 dispatcher.io_uring_lane_config = IoUringLanePolicyConfig::conservative();
10520 dispatcher.io_uring_force_compat = false;
10521 assert!(
10522 !dispatcher.advanced_dispatch_enabled(),
10523 "advanced path should be disabled for this test"
10524 );
10525
10526 for request in requests {
10527 dispatcher.dispatch_and_complete(request).await;
10528 }
10529
10530 let _ = runtime.tick().await.expect("tick");
10531
10532 runtime
10533 .eval(
10534 r#"
10535 if (globalThis.err === null) throw new Error("Promise not rejected");
10536 if (globalThis.err.code !== "denied") {
10537 throw new Error("Expected denied code, got: " + globalThis.err.code);
10538 }
10539 "#,
10540 )
10541 .await
10542 .expect("verify denied error");
10543 });
10544 }
10545
10546 #[test]
10547 fn dispatch_allowed_capability_proceeds() {
10548 futures::executor::block_on(async {
10549 let runtime = Rc::new(
10550 PiJsRuntime::with_clock(DeterministicClock::new(0))
10551 .await
10552 .expect("runtime"),
10553 );
10554
10555 runtime
10556 .eval(
10557 r#"
10558 globalThis.result = null;
10559 pi.log("test message").then((r) => { globalThis.result = r; });
10560 "#,
10561 )
10562 .await
10563 .expect("eval");
10564
10565 let requests = runtime.drain_hostcall_requests();
10566 assert_eq!(requests.len(), 1);
10567
10568 let policy = ExtensionPolicy::from_profile(PolicyProfile::Permissive);
10569 let dispatcher = build_dispatcher_with_policy(Rc::clone(&runtime), policy);
10570
10571 for request in requests {
10572 dispatcher.dispatch_and_complete(request).await;
10573 }
10574
10575 let _ = runtime.tick().await.expect("tick");
10576
10577 runtime
10578 .eval(
10579 r#"
10580 if (globalThis.result === null) throw new Error("Promise not resolved");
10581 "#,
10582 )
10583 .await
10584 .expect("verify allowed");
10585 });
10586 }
10587
10588 #[test]
10589 fn dispatch_allowed_capability_still_resolves_when_advanced_path_disabled() {
10590 futures::executor::block_on(async {
10591 let runtime = Rc::new(
10592 PiJsRuntime::with_clock(DeterministicClock::new(0))
10593 .await
10594 .expect("runtime"),
10595 );
10596
10597 runtime
10598 .eval(
10599 r#"
10600 globalThis.result = null;
10601 pi.log("test message").then((r) => { globalThis.result = r; });
10602 "#,
10603 )
10604 .await
10605 .expect("eval");
10606
10607 let requests = runtime.drain_hostcall_requests();
10608 assert_eq!(requests.len(), 1);
10609
10610 let oracle_config = DualExecOracleConfig {
10611 sample_ppm: 0,
10612 ..DualExecOracleConfig::default()
10613 };
10614 let policy = ExtensionPolicy::from_profile(PolicyProfile::Permissive);
10615 let mut dispatcher =
10616 build_dispatcher_with_policy_and_oracle(Rc::clone(&runtime), policy, oracle_config);
10617 dispatcher.io_uring_lane_config = IoUringLanePolicyConfig::conservative();
10618 dispatcher.io_uring_force_compat = false;
10619 assert!(
10620 !dispatcher.advanced_dispatch_enabled(),
10621 "advanced path should be disabled for this test"
10622 );
10623
10624 for request in requests {
10625 dispatcher.dispatch_and_complete(request).await;
10626 }
10627
10628 let _ = runtime.tick().await.expect("tick");
10629
10630 runtime
10631 .eval(
10632 r#"
10633 if (globalThis.result === null) throw new Error("Promise not resolved");
10634 "#,
10635 )
10636 .await
10637 .expect("verify allowed");
10638 });
10639 }
10640
10641 #[test]
10642 fn advanced_dispatch_enabled_when_dual_exec_sampling_non_zero() {
10643 futures::executor::block_on(async {
10644 let runtime = Rc::new(
10645 PiJsRuntime::with_clock(DeterministicClock::new(0))
10646 .await
10647 .expect("runtime"),
10648 );
10649 let oracle_config = DualExecOracleConfig {
10650 sample_ppm: 1,
10651 ..DualExecOracleConfig::default()
10652 };
10653 let dispatcher = build_dispatcher_with_policy_and_oracle(
10654 Rc::clone(&runtime),
10655 ExtensionPolicy::from_profile(PolicyProfile::Permissive),
10656 oracle_config,
10657 );
10658 assert!(dispatcher.advanced_dispatch_enabled());
10659 });
10660 }
10661
10662 #[test]
10663 fn advanced_dispatch_enabled_when_io_uring_is_enabled() {
10664 futures::executor::block_on(async {
10665 let runtime = Rc::new(
10666 PiJsRuntime::with_clock(DeterministicClock::new(0))
10667 .await
10668 .expect("runtime"),
10669 );
10670 let oracle_config = DualExecOracleConfig {
10671 sample_ppm: 0,
10672 ..DualExecOracleConfig::default()
10673 };
10674 let mut dispatcher = build_dispatcher_with_policy_and_oracle(
10675 Rc::clone(&runtime),
10676 ExtensionPolicy::from_profile(PolicyProfile::Permissive),
10677 oracle_config,
10678 );
10679 dispatcher.io_uring_lane_config = IoUringLanePolicyConfig {
10680 enabled: true,
10681 ring_available: true,
10682 max_queue_depth: 256,
10683 allow_filesystem: true,
10684 allow_network: true,
10685 };
10686 assert!(dispatcher.advanced_dispatch_enabled());
10687 });
10688 }
10689
10690 #[test]
10691 fn advanced_dispatch_enabled_when_io_uring_force_compat_is_set() {
10692 futures::executor::block_on(async {
10693 let runtime = Rc::new(
10694 PiJsRuntime::with_clock(DeterministicClock::new(0))
10695 .await
10696 .expect("runtime"),
10697 );
10698 let oracle_config = DualExecOracleConfig {
10699 sample_ppm: 0,
10700 ..DualExecOracleConfig::default()
10701 };
10702 let mut dispatcher = build_dispatcher_with_policy_and_oracle(
10703 Rc::clone(&runtime),
10704 ExtensionPolicy::from_profile(PolicyProfile::Permissive),
10705 oracle_config,
10706 );
10707 dispatcher.io_uring_lane_config = IoUringLanePolicyConfig::conservative();
10708 dispatcher.io_uring_force_compat = true;
10709 assert!(dispatcher.advanced_dispatch_enabled());
10710 });
10711 }
10712
10713 #[test]
10714 fn dispatch_strict_mode_denies_unknown_capability() {
10715 futures::executor::block_on(async {
10716 let runtime = Rc::new(
10717 PiJsRuntime::with_clock(DeterministicClock::new(0))
10718 .await
10719 .expect("runtime"),
10720 );
10721
10722 runtime
10723 .eval(
10724 r#"
10725 globalThis.err = null;
10726 pi.http({ url: "http://localhost" }).catch((e) => { globalThis.err = e; });
10727 "#,
10728 )
10729 .await
10730 .expect("eval");
10731
10732 let requests = runtime.drain_hostcall_requests();
10733 assert_eq!(requests.len(), 1);
10734
10735 let policy = ExtensionPolicy {
10737 mode: ExtensionPolicyMode::Strict,
10738 max_memory_mb: 256,
10739 default_caps: Vec::new(),
10740 deny_caps: Vec::new(),
10741 per_extension: HashMap::new(),
10742 ..Default::default()
10743 };
10744 let dispatcher = build_dispatcher_with_policy(Rc::clone(&runtime), policy);
10745
10746 for request in requests {
10747 dispatcher.dispatch_and_complete(request).await;
10748 }
10749
10750 let _ = runtime.tick().await.expect("tick");
10751
10752 runtime
10753 .eval(
10754 r#"
10755 if (globalThis.err === null) throw new Error("Promise not rejected");
10756 if (globalThis.err.code !== "denied") {
10757 throw new Error("Expected denied code, got: " + globalThis.err.code);
10758 }
10759 "#,
10760 )
10761 .await
10762 .expect("verify strict denied");
10763 });
10764 }
10765
10766 #[test]
10767 fn protocol_dispatch_denied_returns_error() {
10768 futures::executor::block_on(async {
10769 let runtime = Rc::new(
10770 PiJsRuntime::with_clock(DeterministicClock::new(0))
10771 .await
10772 .expect("runtime"),
10773 );
10774 let policy = ExtensionPolicy::from_profile(PolicyProfile::Safe);
10776 let dispatcher = build_dispatcher_with_policy(Rc::clone(&runtime), policy);
10777
10778 let message = ExtensionMessage {
10779 id: "msg-policy-deny".to_string(),
10780 version: PROTOCOL_VERSION.to_string(),
10781 body: ExtensionBody::HostCall(HostCallPayload {
10782 call_id: "call-policy-deny".to_string(),
10783 capability: "exec".to_string(),
10784 method: "exec".to_string(),
10785 params: serde_json::json!({ "cmd": "echo hello" }),
10786 timeout_ms: None,
10787 cancel_token: None,
10788 context: None,
10789 }),
10790 };
10791
10792 let response = dispatcher
10793 .dispatch_protocol_message(message)
10794 .await
10795 .expect("protocol dispatch");
10796
10797 match response.body {
10798 ExtensionBody::HostResult(result) => {
10799 assert!(result.is_error, "expected denied error result");
10800 let error = result.error.expect("error payload");
10801 assert_eq!(error.code, HostCallErrorCode::Denied);
10802 assert!(
10803 error.message.contains("exec"),
10804 "error should mention denied capability: {}",
10805 error.message
10806 );
10807 }
10808 other => panic!(),
10809 }
10810 });
10811 }
10812
10813 #[test]
10814 fn dispatch_deny_caps_blocks_http() {
10815 futures::executor::block_on(async {
10816 let runtime = Rc::new(
10817 PiJsRuntime::with_clock(DeterministicClock::new(0))
10818 .await
10819 .expect("runtime"),
10820 );
10821
10822 runtime
10823 .eval(
10824 r#"
10825 globalThis.err = null;
10826 pi.http({ url: "http://localhost" }).catch((e) => { globalThis.err = e; });
10827 "#,
10828 )
10829 .await
10830 .expect("eval");
10831
10832 let requests = runtime.drain_hostcall_requests();
10833 assert_eq!(requests.len(), 1);
10834
10835 let policy = ExtensionPolicy {
10836 mode: ExtensionPolicyMode::Permissive,
10837 max_memory_mb: 256,
10838 default_caps: Vec::new(),
10839 deny_caps: vec!["http".to_string()],
10840 per_extension: HashMap::new(),
10841 ..Default::default()
10842 };
10843 let dispatcher = build_dispatcher_with_policy(Rc::clone(&runtime), policy);
10844
10845 for request in requests {
10846 dispatcher.dispatch_and_complete(request).await;
10847 }
10848
10849 let _ = runtime.tick().await.expect("tick");
10850
10851 runtime
10852 .eval(
10853 r#"
10854 if (globalThis.err === null) throw new Error("Promise not rejected");
10855 if (globalThis.err.code !== "denied") {
10856 throw new Error("Expected denied code, got: " + globalThis.err.code);
10857 }
10858 "#,
10859 )
10860 .await
10861 .expect("verify deny_caps http blocked");
10862 });
10863 }
10864
10865 #[test]
10866 fn per_extension_deny_blocks_specific_extension() {
10867 futures::executor::block_on(async {
10868 let runtime = Rc::new(
10869 PiJsRuntime::with_clock(DeterministicClock::new(0))
10870 .await
10871 .expect("runtime"),
10872 );
10873
10874 runtime
10876 .eval(
10877 r#"
10878 globalThis.err = null;
10879 globalThis.result = null;
10880 pi.session("getState", {}).catch((e) => { globalThis.err = e; })
10881 .then((r) => { if (r) globalThis.result = r; });
10882 "#,
10883 )
10884 .await
10885 .expect("eval");
10886
10887 let requests = runtime.drain_hostcall_requests();
10888 assert_eq!(requests.len(), 1);
10889
10890 let mut per_extension = HashMap::new();
10891 per_extension.insert(
10892 "blocked-ext".to_string(),
10893 ExtensionOverride {
10894 mode: None,
10895 allow: Vec::new(),
10896 deny: vec!["session".to_string()],
10897 quota: None,
10898 },
10899 );
10900 let policy = ExtensionPolicy {
10901 mode: ExtensionPolicyMode::Permissive,
10902 max_memory_mb: 256,
10903 default_caps: Vec::new(),
10904 deny_caps: Vec::new(),
10905 per_extension,
10906 ..Default::default()
10907 };
10908 let dispatcher = build_dispatcher_with_policy(Rc::clone(&runtime), policy);
10909
10910 let mut request = requests.into_iter().next().unwrap();
10912 request.extension_id = Some("blocked-ext".to_string());
10913
10914 dispatcher.dispatch_and_complete(request).await;
10915
10916 let _ = runtime.tick().await.expect("tick");
10917
10918 runtime
10919 .eval(
10920 r#"
10921 if (globalThis.err === null) throw new Error("Promise not rejected");
10922 if (globalThis.err.code !== "denied") {
10923 throw new Error("Expected denied code, got: " + globalThis.err.code);
10924 }
10925 "#,
10926 )
10927 .await
10928 .expect("verify per-extension deny");
10929 });
10930 }
10931
10932 #[test]
10933 fn prompt_decision_treated_as_deny_in_dispatcher() {
10934 futures::executor::block_on(async {
10935 let runtime = Rc::new(
10936 PiJsRuntime::with_clock(DeterministicClock::new(0))
10937 .await
10938 .expect("runtime"),
10939 );
10940
10941 runtime
10942 .eval(
10943 r#"
10944 globalThis.err = null;
10945 pi.exec("echo", ["hello"]).catch((e) => { globalThis.err = e; });
10946 "#,
10947 )
10948 .await
10949 .expect("eval");
10950
10951 let requests = runtime.drain_hostcall_requests();
10952 assert_eq!(requests.len(), 1);
10953
10954 let policy = ExtensionPolicy {
10956 mode: ExtensionPolicyMode::Prompt,
10957 max_memory_mb: 256,
10958 default_caps: Vec::new(),
10959 deny_caps: Vec::new(),
10960 per_extension: HashMap::new(),
10961 ..Default::default()
10962 };
10963 let dispatcher = build_dispatcher_with_policy(Rc::clone(&runtime), policy);
10964
10965 for request in requests {
10966 dispatcher.dispatch_and_complete(request).await;
10967 }
10968
10969 let _ = runtime.tick().await.expect("tick");
10970
10971 runtime
10972 .eval(
10973 r#"
10974 if (globalThis.err === null) throw new Error("Promise not rejected");
10975 if (globalThis.err.code !== "denied") {
10976 throw new Error("Expected denied, got: " + globalThis.err.code);
10977 }
10978 "#,
10979 )
10980 .await
10981 .expect("verify prompt treated as deny");
10982 });
10983 }
10984
10985 #[test]
10990 fn protocol_hostcall_op_extracts_op_field() {
10991 let params = serde_json::json!({ "op": "get_state" });
10992 assert_eq!(protocol_hostcall_op(¶ms), Some("get_state"));
10993 }
10994
10995 #[test]
10996 fn protocol_hostcall_op_extracts_method_field() {
10997 let params = serde_json::json!({ "method": "do_thing" });
10998 assert_eq!(protocol_hostcall_op(¶ms), Some("do_thing"));
10999 }
11000
11001 #[test]
11002 fn protocol_hostcall_op_extracts_name_field() {
11003 let params = serde_json::json!({ "name": "my_event" });
11004 assert_eq!(protocol_hostcall_op(¶ms), Some("my_event"));
11005 }
11006
11007 #[test]
11008 fn protocol_hostcall_op_prefers_op_over_method_and_name() {
11009 let params = serde_json::json!({ "op": "a", "method": "b", "name": "c" });
11010 assert_eq!(protocol_hostcall_op(¶ms), Some("a"));
11011 }
11012
11013 #[test]
11014 fn protocol_hostcall_op_falls_back_to_method_when_op_missing() {
11015 let params = serde_json::json!({ "method": "b", "name": "c" });
11016 assert_eq!(protocol_hostcall_op(¶ms), Some("b"));
11017 }
11018
11019 #[test]
11020 fn protocol_hostcall_op_returns_none_for_empty_or_whitespace() {
11021 assert_eq!(protocol_hostcall_op(&serde_json::json!({})), None);
11022 assert_eq!(protocol_hostcall_op(&serde_json::json!({ "op": "" })), None);
11023 assert_eq!(
11024 protocol_hostcall_op(&serde_json::json!({ "op": " " })),
11025 None
11026 );
11027 }
11028
11029 #[test]
11030 fn protocol_hostcall_op_trims_whitespace() {
11031 let params = serde_json::json!({ "op": " get_state " });
11032 assert_eq!(protocol_hostcall_op(¶ms), Some("get_state"));
11033 }
11034
11035 #[test]
11036 fn protocol_hostcall_op_returns_none_for_non_string_values() {
11037 assert_eq!(protocol_hostcall_op(&serde_json::json!({ "op": 42 })), None);
11038 assert_eq!(
11039 protocol_hostcall_op(&serde_json::json!({ "op": true })),
11040 None
11041 );
11042 assert_eq!(
11043 protocol_hostcall_op(&serde_json::json!({ "op": null })),
11044 None
11045 );
11046 }
11047
11048 #[test]
11049 fn parse_protocol_hostcall_method_normalizes_case_and_whitespace() {
11050 assert!(matches!(
11051 parse_protocol_hostcall_method(" Tool "),
11052 Some(ProtocolHostcallMethod::Tool)
11053 ));
11054 assert!(matches!(
11055 parse_protocol_hostcall_method("EXEC"),
11056 Some(ProtocolHostcallMethod::Exec)
11057 ));
11058 assert!(matches!(
11059 parse_protocol_hostcall_method(" session "),
11060 Some(ProtocolHostcallMethod::Session)
11061 ));
11062 }
11063
11064 #[test]
11065 fn parse_protocol_hostcall_method_rejects_unknown_or_empty_values() {
11066 assert!(parse_protocol_hostcall_method("").is_none());
11067 assert!(parse_protocol_hostcall_method(" ").is_none());
11068 assert!(parse_protocol_hostcall_method("not_a_method").is_none());
11069 }
11070
11071 #[test]
11072 fn protocol_error_fallback_reason_preserves_invalid_request_taxonomy() {
11073 assert_eq!(
11074 protocol_error_fallback_reason("tool", "invalid_request"),
11075 "schema_validation_failed"
11076 );
11077 assert_eq!(
11078 protocol_error_fallback_reason(" SESSION ", "invalid_request"),
11079 "schema_validation_failed"
11080 );
11081 assert_eq!(
11082 protocol_error_fallback_reason("unknown", "invalid_request"),
11083 "unsupported_method_fallback"
11084 );
11085 }
11086
11087 #[test]
11088 fn protocol_error_fallback_reason_maps_non_invalid_request_codes() {
11089 assert_eq!(
11090 protocol_error_fallback_reason("tool", "denied"),
11091 "policy_denied"
11092 );
11093 assert_eq!(
11094 protocol_error_fallback_reason("tool", "timeout"),
11095 "handler_timeout"
11096 );
11097 assert_eq!(
11098 protocol_error_fallback_reason("tool", "tool_error"),
11099 "handler_error"
11100 );
11101 assert_eq!(
11102 protocol_error_fallback_reason("tool", "unexpected"),
11103 "runtime_internal_error"
11104 );
11105 }
11106
11107 #[test]
11108 fn protocol_normalize_output_passes_object_through() {
11109 let obj = serde_json::json!({ "key": "value" });
11110 assert_eq!(protocol_normalize_output(obj.clone()), obj);
11111 }
11112
11113 #[test]
11114 fn protocol_normalize_output_wraps_non_object_in_value_field() {
11115 assert_eq!(
11116 protocol_normalize_output(serde_json::json!("hello")),
11117 serde_json::json!({ "value": "hello" })
11118 );
11119 assert_eq!(
11120 protocol_normalize_output(serde_json::json!(42)),
11121 serde_json::json!({ "value": 42 })
11122 );
11123 assert_eq!(
11124 protocol_normalize_output(serde_json::json!(true)),
11125 serde_json::json!({ "value": true })
11126 );
11127 assert_eq!(
11128 protocol_normalize_output(Value::Null),
11129 serde_json::json!({ "value": null })
11130 );
11131 assert_eq!(
11132 protocol_normalize_output(serde_json::json!([1, 2, 3])),
11133 serde_json::json!({ "value": [1, 2, 3] })
11134 );
11135 }
11136
11137 #[test]
11138 fn protocol_error_code_maps_known_codes() {
11139 assert_eq!(protocol_error_code("timeout"), HostCallErrorCode::Timeout);
11140 assert_eq!(protocol_error_code("denied"), HostCallErrorCode::Denied);
11141 assert_eq!(protocol_error_code("io"), HostCallErrorCode::Io);
11142 assert_eq!(protocol_error_code("tool_error"), HostCallErrorCode::Io);
11143 assert_eq!(
11144 protocol_error_code("invalid_request"),
11145 HostCallErrorCode::InvalidRequest
11146 );
11147 }
11148
11149 #[test]
11150 fn protocol_error_code_unknown_maps_to_internal() {
11151 assert_eq!(
11152 protocol_error_code("something_else"),
11153 HostCallErrorCode::Internal
11154 );
11155 assert_eq!(protocol_error_code(""), HostCallErrorCode::Internal);
11156 assert_eq!(
11157 protocol_error_code("not_a_code"),
11158 HostCallErrorCode::Internal
11159 );
11160 }
11161
11162 #[test]
11163 fn protocol_error_code_normalizes_case_and_whitespace() {
11164 assert_eq!(protocol_error_code(" Timeout "), HostCallErrorCode::Timeout);
11165 assert_eq!(protocol_error_code("DENIED"), HostCallErrorCode::Denied);
11166 assert_eq!(protocol_error_code(" Tool_Error "), HostCallErrorCode::Io);
11167 assert_eq!(
11168 protocol_error_code(" Invalid_Request "),
11169 HostCallErrorCode::InvalidRequest
11170 );
11171 }
11172
11173 #[test]
11174 fn protocol_error_fallback_reason_normalizes_code_before_taxonomy_mapping() {
11175 assert_eq!(
11176 protocol_error_fallback_reason(" session ", " INVALID_REQUEST "),
11177 "schema_validation_failed"
11178 );
11179 assert_eq!(
11180 protocol_error_fallback_reason("unknown", " INVALID_REQUEST "),
11181 "unsupported_method_fallback"
11182 );
11183 assert_eq!(
11184 protocol_error_fallback_reason("tool", " TOOL_ERROR "),
11185 "handler_error"
11186 );
11187 }
11188
11189 fn test_protocol_payload(call_id: &str) -> HostCallPayload {
11190 HostCallPayload {
11191 call_id: call_id.to_string(),
11192 capability: "test".to_string(),
11193 method: "tool".to_string(),
11194 params: serde_json::json!({}),
11195 timeout_ms: None,
11196 cancel_token: None,
11197 context: None,
11198 }
11199 }
11200
11201 fn test_hostcall_request(call_id: &str, kind: HostcallKind, payload: Value) -> HostcallRequest {
11202 HostcallRequest {
11203 call_id: call_id.to_string(),
11204 kind,
11205 payload,
11206 trace_id: 0,
11207 extension_id: Some("ext.protocol.params".to_string()),
11208 }
11209 }
11210
11211 #[test]
11212 fn protocol_params_from_request_matches_hostcall_request_params_for_hash() {
11213 let requests = vec![
11214 test_hostcall_request(
11215 "tool-case",
11216 HostcallKind::Tool {
11217 name: "read".to_string(),
11218 },
11219 serde_json::json!({ "path": "README.md" }),
11220 ),
11221 test_hostcall_request(
11222 "tool-non-object-case",
11223 HostcallKind::Tool {
11224 name: "read".to_string(),
11225 },
11226 serde_json::json!(["README.md", "Cargo.toml"]),
11227 ),
11228 test_hostcall_request(
11229 "exec-object-case",
11230 HostcallKind::Exec {
11231 cmd: "echo from kind".to_string(),
11232 },
11233 serde_json::json!({
11234 "command": "legacy alias should be removed",
11235 "cmd": "payload override should lose",
11236 "args": ["hello"],
11237 }),
11238 ),
11239 test_hostcall_request(
11240 "exec-non-object-case",
11241 HostcallKind::Exec {
11242 cmd: "bash -lc true".to_string(),
11243 },
11244 serde_json::json!("raw payload"),
11245 ),
11246 test_hostcall_request(
11247 "http-case",
11248 HostcallKind::Http,
11249 serde_json::json!({
11250 "url": "https://example.com",
11251 "method": "GET",
11252 }),
11253 ),
11254 test_hostcall_request(
11255 "http-non-object-case",
11256 HostcallKind::Http,
11257 serde_json::json!("https://example.com/raw"),
11258 ),
11259 test_hostcall_request(
11260 "session-case",
11261 HostcallKind::Session {
11262 op: "get_state".to_string(),
11263 },
11264 serde_json::json!({
11265 "op": "payload override should lose",
11266 "includeEntries": true,
11267 }),
11268 ),
11269 test_hostcall_request(
11270 "ui-non-object-case",
11271 HostcallKind::Ui {
11272 op: "set_status".to_string(),
11273 },
11274 serde_json::json!("ready"),
11275 ),
11276 test_hostcall_request(
11277 "events-null-case",
11278 HostcallKind::Events {
11279 op: "list_flags".to_string(),
11280 },
11281 Value::Null,
11282 ),
11283 test_hostcall_request(
11284 "log-case",
11285 HostcallKind::Log,
11286 serde_json::json!({
11287 "level": "info",
11288 "event": "test.protocol",
11289 "message": "hello",
11290 }),
11291 ),
11292 test_hostcall_request(
11293 "log-non-object-case",
11294 HostcallKind::Log,
11295 serde_json::json!("raw-log-payload"),
11296 ),
11297 test_hostcall_request(
11298 "log-array-case",
11299 HostcallKind::Log,
11300 serde_json::json!(["raw", "log", "payload"]),
11301 ),
11302 test_hostcall_request("log-null-case", HostcallKind::Log, Value::Null),
11303 ];
11304
11305 for request in requests {
11306 assert_eq!(
11307 protocol_params_from_request(&request),
11308 request.params_for_hash(),
11309 "protocol params shape diverged for {}",
11310 request.call_id
11311 );
11312 }
11313 }
11314
11315 #[test]
11316 fn protocol_params_from_request_preserves_reserved_key_precedence() {
11317 let exec_request = test_hostcall_request(
11318 "exec-precedence",
11319 HostcallKind::Exec {
11320 cmd: "echo from kind".to_string(),
11321 },
11322 serde_json::json!({
11323 "command": "legacy alias",
11324 "cmd": "payload cmd should not win",
11325 "args": ["a", "b"],
11326 }),
11327 );
11328 let exec_params = protocol_params_from_request(&exec_request);
11329 assert_eq!(exec_params["cmd"], serde_json::json!("echo from kind"));
11330 assert_eq!(exec_params.get("command"), None);
11331
11332 for (call_id, kind) in [
11333 (
11334 "session-precedence",
11335 HostcallKind::Session {
11336 op: "get_state".to_string(),
11337 },
11338 ),
11339 (
11340 "ui-precedence",
11341 HostcallKind::Ui {
11342 op: "set_status".to_string(),
11343 },
11344 ),
11345 (
11346 "events-precedence",
11347 HostcallKind::Events {
11348 op: "list_flags".to_string(),
11349 },
11350 ),
11351 ] {
11352 let request = test_hostcall_request(
11353 call_id,
11354 kind.clone(),
11355 serde_json::json!({ "op": "payload op should not win", "x": 1 }),
11356 );
11357 let params = protocol_params_from_request(&request);
11358 let expected_op = match kind {
11359 HostcallKind::Session { ref op }
11360 | HostcallKind::Ui { ref op }
11361 | HostcallKind::Events { ref op } => op.clone(),
11362 _ => unreachable!("loop only includes op-based hostcall kinds"),
11363 };
11364 assert_eq!(params["op"], Value::String(expected_op));
11365 }
11366 }
11367
11368 fn assert_protocol_result_equivalent_except_error_details(
11369 plain: &HostResultPayload,
11370 traced: &HostResultPayload,
11371 ) {
11372 assert_eq!(plain.call_id, traced.call_id);
11373 assert_eq!(plain.output, traced.output);
11374 assert_eq!(plain.is_error, traced.is_error);
11375 assert_eq!(
11376 plain.chunk.as_ref().map(|chunk| {
11377 (
11378 chunk.index,
11379 chunk.is_last,
11380 chunk
11381 .backpressure
11382 .as_ref()
11383 .map(|bp| (bp.credits, bp.delay_ms)),
11384 )
11385 }),
11386 traced.chunk.as_ref().map(|chunk| {
11387 (
11388 chunk.index,
11389 chunk.is_last,
11390 chunk
11391 .backpressure
11392 .as_ref()
11393 .map(|bp| (bp.credits, bp.delay_ms)),
11394 )
11395 })
11396 );
11397 match (plain.error.as_ref(), traced.error.as_ref()) {
11398 (None, None) => {}
11399 (Some(plain_error), Some(traced_error)) => {
11400 assert_eq!(plain_error.code, traced_error.code);
11401 assert_eq!(plain_error.message, traced_error.message);
11402 assert_eq!(plain_error.retryable, traced_error.retryable);
11403 }
11404 _ => panic!(),
11405 }
11406 }
11407
11408 #[test]
11409 fn hostcall_outcome_to_protocol_result_success() {
11410 let payload = test_protocol_payload("call-1");
11411 let result = hostcall_outcome_to_protocol_result(
11412 &payload.call_id,
11413 HostcallOutcome::Success(serde_json::json!({ "ok": true })),
11414 );
11415 assert_eq!(result.call_id, "call-1");
11416 assert!(!result.is_error);
11417 assert!(result.error.is_none());
11418 assert!(result.chunk.is_none());
11419 assert!(result.output.is_object());
11420 }
11421
11422 #[test]
11423 fn hostcall_outcome_to_protocol_result_success_wraps_non_object() {
11424 let payload = test_protocol_payload("call-2");
11425 let result = hostcall_outcome_to_protocol_result(
11426 &payload.call_id,
11427 HostcallOutcome::Success(serde_json::json!("plain string")),
11428 );
11429 assert!(!result.is_error);
11430 assert_eq!(
11431 result.output,
11432 serde_json::json!({ "value": "plain string" })
11433 );
11434 }
11435
11436 #[test]
11437 fn hostcall_outcome_to_protocol_result_stream_chunk() {
11438 let payload = test_protocol_payload("call-3");
11439 let result = hostcall_outcome_to_protocol_result(
11440 &payload.call_id,
11441 HostcallOutcome::StreamChunk {
11442 sequence: 5,
11443 chunk: serde_json::json!({ "stdout": "hello\n" }),
11444 is_final: false,
11445 },
11446 );
11447 assert_eq!(result.call_id, "call-3");
11448 assert!(!result.is_error);
11449 assert!(result.error.is_none());
11450 let chunk = result.chunk.expect("should have chunk");
11451 assert_eq!(chunk.index, 5);
11452 assert!(!chunk.is_last);
11453 assert_eq!(result.output["sequence"], 5);
11454 assert!(!result.output["isFinal"].as_bool().unwrap());
11455 }
11456
11457 #[test]
11458 fn hostcall_outcome_to_protocol_result_stream_chunk_final() {
11459 let payload = test_protocol_payload("call-4");
11460 let result = hostcall_outcome_to_protocol_result(
11461 &payload.call_id,
11462 HostcallOutcome::StreamChunk {
11463 sequence: 10,
11464 chunk: serde_json::json!({ "code": 0 }),
11465 is_final: true,
11466 },
11467 );
11468 let chunk = result.chunk.expect("should have chunk");
11469 assert!(chunk.is_last);
11470 assert_eq!(chunk.index, 10);
11471 assert!(result.output["isFinal"].as_bool().unwrap());
11472 }
11473
11474 #[test]
11475 fn hostcall_outcome_to_protocol_result_error() {
11476 let payload = test_protocol_payload("call-5");
11477 let result = hostcall_outcome_to_protocol_result(
11478 &payload.call_id,
11479 HostcallOutcome::Error {
11480 code: "io".to_string(),
11481 message: "disk full".to_string(),
11482 },
11483 );
11484 assert_eq!(result.call_id, "call-5");
11485 assert!(result.is_error);
11486 assert!(result.chunk.is_none());
11487 let error = result.error.expect("should have error");
11488 assert_eq!(error.code, HostCallErrorCode::Io);
11489 assert_eq!(error.message, "disk full");
11490 }
11491
11492 #[test]
11493 fn hostcall_outcome_to_protocol_result_error_unknown_code_maps_to_internal() {
11494 let payload = test_protocol_payload("call-6");
11495 let result = hostcall_outcome_to_protocol_result(
11496 &payload.call_id,
11497 HostcallOutcome::Error {
11498 code: "something_weird".to_string(),
11499 message: "unexpected".to_string(),
11500 },
11501 );
11502 let error = result.error.expect("should have error");
11503 assert_eq!(error.code, HostCallErrorCode::Internal);
11504 }
11505
11506 #[test]
11507 fn hostcall_outcome_to_protocol_result_error_normalizes_mixed_case_code() {
11508 let payload = test_protocol_payload("call-6b");
11509 let result = hostcall_outcome_to_protocol_result(
11510 &payload.call_id,
11511 HostcallOutcome::Error {
11512 code: " Invalid_Request ".to_string(),
11513 message: "normalized".to_string(),
11514 },
11515 );
11516 let error = result.error.expect("should have error");
11517 assert_eq!(error.code, HostCallErrorCode::InvalidRequest);
11518 assert_eq!(error.message, "normalized");
11519 }
11520
11521 #[test]
11522 fn hostcall_outcome_to_protocol_result_error_normalizes_denied_timeout_and_tool_error_alias() {
11523 let cases = [
11524 (" DeNied ", HostCallErrorCode::Denied),
11525 (" TimeOut ", HostCallErrorCode::Timeout),
11526 (" TOOL_ERROR ", HostCallErrorCode::Io),
11527 ];
11528
11529 for (idx, (raw_code, expected_code)) in cases.into_iter().enumerate() {
11530 let payload = test_protocol_payload(&format!("call-plain-normalize-{idx}"));
11531 let message = format!("normalized-{idx}");
11532 let result = hostcall_outcome_to_protocol_result(
11533 &payload.call_id,
11534 HostcallOutcome::Error {
11535 code: raw_code.to_string(),
11536 message: message.clone(),
11537 },
11538 );
11539
11540 let error = result.error.expect("should have error");
11541 assert_eq!(error.code, expected_code, "raw code: {raw_code}");
11542 assert_eq!(error.message, message);
11543 }
11544 }
11545
11546 #[test]
11547 fn hostcall_outcome_to_protocol_result_with_trace_success_equivalent_to_plain() {
11548 let payload = test_protocol_payload("call-trace-success");
11549 let outcome = HostcallOutcome::Success(serde_json::json!({
11550 "ok": true,
11551 "nested": { "n": 7 }
11552 }));
11553 let plain = hostcall_outcome_to_protocol_result(&payload.call_id, outcome.clone());
11554 let traced = hostcall_outcome_to_protocol_result_with_trace(&payload, outcome);
11555
11556 assert_protocol_result_equivalent_except_error_details(&plain, &traced);
11557 assert!(traced.error.is_none());
11558 }
11559
11560 #[test]
11561 fn hostcall_outcome_to_protocol_result_with_trace_stream_equivalent_to_plain() {
11562 let payload = test_protocol_payload("call-trace-stream");
11563 let outcome = HostcallOutcome::StreamChunk {
11564 sequence: 3,
11565 chunk: serde_json::json!({ "stdout": "chunk" }),
11566 is_final: false,
11567 };
11568 let plain = hostcall_outcome_to_protocol_result(&payload.call_id, outcome.clone());
11569 let traced = hostcall_outcome_to_protocol_result_with_trace(&payload, outcome);
11570
11571 assert_protocol_result_equivalent_except_error_details(&plain, &traced);
11572 assert!(traced.error.is_none());
11573 }
11574
11575 #[test]
11576 fn hostcall_outcome_to_protocol_result_with_trace_error_adds_details_without_mutating_error_core()
11577 {
11578 let mut payload = test_protocol_payload("call-trace-error");
11579 payload.method = "tool".to_string();
11580 payload.params = serde_json::json!({ "zeta": 1, "alpha": 2 });
11581 let outcome = HostcallOutcome::Error {
11582 code: "invalid_request".to_string(),
11583 message: "invalid payload".to_string(),
11584 };
11585 let plain = hostcall_outcome_to_protocol_result(&payload.call_id, outcome.clone());
11586 let traced = hostcall_outcome_to_protocol_result_with_trace(&payload, outcome);
11587
11588 assert_protocol_result_equivalent_except_error_details(&plain, &traced);
11589
11590 let plain_error = plain.error.expect("plain conversion should include error");
11591 assert!(
11592 plain_error.details.is_none(),
11593 "plain conversion should not inject trace details"
11594 );
11595 let traced_error = traced.error.expect("trace conversion should include error");
11596 let details = traced_error
11597 .details
11598 .expect("trace conversion should include structured details");
11599 assert_eq!(
11600 details["dispatcherDecisionTrace"]["fallbackReason"],
11601 serde_json::json!("schema_validation_failed")
11602 );
11603 assert_eq!(
11604 details["schemaDiff"]["observedParamKeys"],
11605 serde_json::json!(["alpha", "zeta"])
11606 );
11607 assert_eq!(
11608 details["extensionInput"]["callId"],
11609 serde_json::json!("call-trace-error")
11610 );
11611 assert_eq!(
11612 details["extensionOutput"]["code"],
11613 serde_json::json!("invalid_request")
11614 );
11615 }
11616
11617 #[test]
11618 fn hostcall_outcome_to_protocol_result_with_trace_normalizes_invalid_request_taxonomy() {
11619 let mut known_method_payload = test_protocol_payload("call-trace-error-known");
11620 known_method_payload.method = " TOOL ".to_string();
11621 let known_method_result = hostcall_outcome_to_protocol_result_with_trace(
11622 &known_method_payload,
11623 HostcallOutcome::Error {
11624 code: " INVALID_REQUEST ".to_string(),
11625 message: "bad request".to_string(),
11626 },
11627 );
11628 let known_method_error = known_method_result.error.expect("expected error");
11629 assert_eq!(known_method_error.code, HostCallErrorCode::InvalidRequest);
11630 let known_details = known_method_error.details.expect("expected details");
11631 assert_eq!(
11632 known_details["dispatcherDecisionTrace"]["fallbackReason"],
11633 serde_json::json!("schema_validation_failed")
11634 );
11635
11636 let mut unknown_method_payload = test_protocol_payload("call-trace-error-unknown");
11637 unknown_method_payload.method = "custom_method".to_string();
11638 let unknown_method_result = hostcall_outcome_to_protocol_result_with_trace(
11639 &unknown_method_payload,
11640 HostcallOutcome::Error {
11641 code: " INVALID_REQUEST ".to_string(),
11642 message: "bad request".to_string(),
11643 },
11644 );
11645 let unknown_method_error = unknown_method_result.error.expect("expected error");
11646 assert_eq!(unknown_method_error.code, HostCallErrorCode::InvalidRequest);
11647 let unknown_details = unknown_method_error.details.expect("expected details");
11648 assert_eq!(
11649 unknown_details["dispatcherDecisionTrace"]["fallbackReason"],
11650 serde_json::json!("unsupported_method_fallback")
11651 );
11652 }
11653
11654 #[test]
11655 fn hostcall_outcome_to_protocol_result_with_trace_normalizes_tool_error_taxonomy() {
11656 let mut payload = test_protocol_payload("call-trace-error-tool");
11657 payload.method = "tool".to_string();
11658 let result = hostcall_outcome_to_protocol_result_with_trace(
11659 &payload,
11660 HostcallOutcome::Error {
11661 code: " TOOL_ERROR ".to_string(),
11662 message: "handler exploded".to_string(),
11663 },
11664 );
11665
11666 let error = result.error.expect("expected error");
11667 assert_eq!(error.code, HostCallErrorCode::Io);
11668 let details = error.details.expect("expected details");
11669 assert_eq!(
11670 details["dispatcherDecisionTrace"]["fallbackReason"],
11671 serde_json::json!("handler_error")
11672 );
11673 assert_eq!(
11674 details["extensionOutput"]["code"],
11675 serde_json::json!(" TOOL_ERROR ")
11676 );
11677 }
11678
11679 #[test]
11680 fn hostcall_outcome_to_protocol_result_with_trace_normalizes_timeout_taxonomy() {
11681 let mut payload = test_protocol_payload("call-trace-error-timeout");
11682 payload.method = "exec".to_string();
11683 let result = hostcall_outcome_to_protocol_result_with_trace(
11684 &payload,
11685 HostcallOutcome::Error {
11686 code: " TimeOut ".to_string(),
11687 message: "handler timed out".to_string(),
11688 },
11689 );
11690
11691 let error = result.error.expect("expected error");
11692 assert_eq!(error.code, HostCallErrorCode::Timeout);
11693 let details = error.details.expect("expected details");
11694 assert_eq!(
11695 details["dispatcherDecisionTrace"]["fallbackReason"],
11696 serde_json::json!("handler_timeout")
11697 );
11698 assert_eq!(
11699 details["extensionOutput"]["code"],
11700 serde_json::json!(" TimeOut ")
11701 );
11702 }
11703
11704 #[test]
11705 fn hostcall_outcome_to_protocol_result_with_trace_normalizes_denied_taxonomy() {
11706 let mut payload = test_protocol_payload("call-trace-error-denied");
11707 payload.method = "session".to_string();
11708 let result = hostcall_outcome_to_protocol_result_with_trace(
11709 &payload,
11710 HostcallOutcome::Error {
11711 code: " DeNied ".to_string(),
11712 message: "blocked by policy".to_string(),
11713 },
11714 );
11715
11716 let error = result.error.expect("expected error");
11717 assert_eq!(error.code, HostCallErrorCode::Denied);
11718 let details = error.details.expect("expected details");
11719 assert_eq!(
11720 details["dispatcherDecisionTrace"]["fallbackReason"],
11721 serde_json::json!("policy_denied")
11722 );
11723 assert_eq!(
11724 details["extensionOutput"]["code"],
11725 serde_json::json!(" DeNied ")
11726 );
11727 }
11728
11729 #[test]
11730 fn hostcall_outcome_to_protocol_result_with_trace_normalizes_unknown_code_to_internal_taxonomy()
11731 {
11732 let mut payload = test_protocol_payload("call-trace-error-unknown-code");
11733 payload.method = "tool".to_string();
11734 let result = hostcall_outcome_to_protocol_result_with_trace(
11735 &payload,
11736 HostcallOutcome::Error {
11737 code: " SOME_NEW_CODE ".to_string(),
11738 message: "unexpected runtime state".to_string(),
11739 },
11740 );
11741
11742 let error = result.error.expect("expected error");
11743 assert_eq!(error.code, HostCallErrorCode::Internal);
11744 let details = error.details.expect("expected details");
11745 assert_eq!(
11746 details["dispatcherDecisionTrace"]["fallbackReason"],
11747 serde_json::json!("runtime_internal_error")
11748 );
11749 assert_eq!(
11750 details["extensionOutput"]["code"],
11751 serde_json::json!(" SOME_NEW_CODE ")
11752 );
11753 }
11754
11755 #[test]
11756 fn hostcall_code_to_str_roundtrips_all_variants() {
11757 use crate::connectors::HostCallErrorCode;
11758 assert_eq!(hostcall_code_to_str(HostCallErrorCode::Timeout), "timeout");
11759 assert_eq!(hostcall_code_to_str(HostCallErrorCode::Denied), "denied");
11760 assert_eq!(hostcall_code_to_str(HostCallErrorCode::Io), "io");
11761 assert_eq!(
11762 hostcall_code_to_str(HostCallErrorCode::InvalidRequest),
11763 "invalid_request"
11764 );
11765 assert_eq!(
11766 hostcall_code_to_str(HostCallErrorCode::Internal),
11767 "internal"
11768 );
11769 }
11770
11771 #[test]
11776 fn protocol_dispatch_tool_success() {
11777 futures::executor::block_on(async {
11778 let temp_dir = tempfile::tempdir().expect("tempdir");
11779 std::fs::write(temp_dir.path().join("file.txt"), "protocol test content")
11780 .expect("write");
11781
11782 let runtime = Rc::new(
11783 PiJsRuntime::with_clock(DeterministicClock::new(0))
11784 .await
11785 .expect("runtime"),
11786 );
11787 let dispatcher = ExtensionDispatcher::new_with_policy(
11788 Rc::clone(&runtime),
11789 Arc::new(ToolRegistry::new(&["read"], temp_dir.path(), None)),
11790 Arc::new(HttpConnector::with_defaults()),
11791 Arc::new(NullSession),
11792 Arc::new(NullUiHandler),
11793 temp_dir.path().to_path_buf(),
11794 ExtensionPolicy::from_profile(PolicyProfile::Permissive),
11795 );
11796
11797 let message = ExtensionMessage {
11798 id: "msg-tool-proto".to_string(),
11799 version: PROTOCOL_VERSION.to_string(),
11800 body: ExtensionBody::HostCall(HostCallPayload {
11801 call_id: "call-tool-proto".to_string(),
11802 capability: "read".to_string(),
11803 method: "tool".to_string(),
11804 params: serde_json::json!({ "name": "read", "input": { "path": "file.txt" } }),
11805 timeout_ms: None,
11806 cancel_token: None,
11807 context: None,
11808 }),
11809 };
11810
11811 let response = dispatcher
11812 .dispatch_protocol_message(message)
11813 .await
11814 .expect("protocol tool dispatch");
11815
11816 match response.body {
11817 ExtensionBody::HostResult(result) => {
11818 assert!(!result.is_error, "expected success: {result:?}");
11819 assert!(result.output.is_object());
11820 }
11821 other => panic!(),
11822 }
11823 });
11824 }
11825
11826 #[test]
11827 fn protocol_dispatch_tool_missing_name_returns_invalid_request() {
11828 futures::executor::block_on(async {
11829 let runtime = Rc::new(
11830 PiJsRuntime::with_clock(DeterministicClock::new(0))
11831 .await
11832 .expect("runtime"),
11833 );
11834 let dispatcher = build_dispatcher(Rc::clone(&runtime));
11835
11836 let message = ExtensionMessage {
11837 id: "msg-tool-noname".to_string(),
11838 version: PROTOCOL_VERSION.to_string(),
11839 body: ExtensionBody::HostCall(HostCallPayload {
11840 call_id: "call-tool-noname".to_string(),
11841 capability: "tool".to_string(),
11842 method: "tool".to_string(),
11843 params: serde_json::json!({ "input": {} }),
11844 timeout_ms: None,
11845 cancel_token: None,
11846 context: None,
11847 }),
11848 };
11849
11850 let response = dispatcher
11851 .dispatch_protocol_message(message)
11852 .await
11853 .expect("protocol dispatch");
11854
11855 match response.body {
11856 ExtensionBody::HostResult(result) => {
11857 assert!(result.is_error);
11858 let error = result.error.expect("error");
11859 assert_eq!(error.code, HostCallErrorCode::InvalidRequest);
11860 assert!(
11861 error.message.contains("method") || error.message.contains("tool"),
11862 "error should mention 'method' or 'tool': {}",
11863 error.message
11864 );
11865 }
11866 other => panic!(),
11867 }
11868 });
11869 }
11870
11871 #[test]
11872 fn protocol_dispatch_tool_empty_name_returns_invalid_request() {
11873 futures::executor::block_on(async {
11874 let runtime = Rc::new(
11875 PiJsRuntime::with_clock(DeterministicClock::new(0))
11876 .await
11877 .expect("runtime"),
11878 );
11879 let dispatcher = build_dispatcher(Rc::clone(&runtime));
11880
11881 let message = ExtensionMessage {
11882 id: "msg-tool-empty".to_string(),
11883 version: PROTOCOL_VERSION.to_string(),
11884 body: ExtensionBody::HostCall(HostCallPayload {
11885 call_id: "call-tool-empty".to_string(),
11886 capability: "tool".to_string(),
11887 method: "tool".to_string(),
11888 params: serde_json::json!({ "name": "", "input": {} }),
11889 timeout_ms: None,
11890 cancel_token: None,
11891 context: None,
11892 }),
11893 };
11894
11895 let response = dispatcher
11896 .dispatch_protocol_message(message)
11897 .await
11898 .expect("protocol dispatch");
11899
11900 match response.body {
11901 ExtensionBody::HostResult(result) => {
11902 assert!(result.is_error);
11903 let error = result.error.expect("error");
11904 assert_eq!(error.code, HostCallErrorCode::InvalidRequest);
11905 }
11906 other => panic!(),
11907 }
11908 });
11909 }
11910
11911 #[test]
11912 fn protocol_dispatch_http_success() {
11913 futures::executor::block_on(async {
11914 let addr = spawn_http_server("protocol http ok");
11915
11916 let runtime = Rc::new(
11917 PiJsRuntime::with_clock(DeterministicClock::new(0))
11918 .await
11919 .expect("runtime"),
11920 );
11921 let dispatcher = ExtensionDispatcher::new_with_policy(
11922 Rc::clone(&runtime),
11923 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
11924 Arc::new(HttpConnector::new(HttpConnectorConfig {
11925 default_timeout_ms: 5000,
11926 require_tls: false,
11927 ..HttpConnectorConfig::default()
11928 })),
11929 Arc::new(NullSession),
11930 Arc::new(NullUiHandler),
11931 PathBuf::from("."),
11932 ExtensionPolicy::from_profile(PolicyProfile::Permissive),
11933 );
11934
11935 let message = ExtensionMessage {
11936 id: "msg-http-proto".to_string(),
11937 version: PROTOCOL_VERSION.to_string(),
11938 body: ExtensionBody::HostCall(HostCallPayload {
11939 call_id: "call-http-proto".to_string(),
11940 capability: "http".to_string(),
11941 method: "http".to_string(),
11942 params: serde_json::json!({
11943 "url": format!("http://{addr}/test"),
11944 "method": "GET",
11945 }),
11946 timeout_ms: None,
11947 cancel_token: None,
11948 context: None,
11949 }),
11950 };
11951
11952 let response = dispatcher
11953 .dispatch_protocol_message(message)
11954 .await
11955 .expect("protocol http dispatch");
11956
11957 match response.body {
11958 ExtensionBody::HostResult(result) => {
11959 assert!(!result.is_error, "expected success: {result:?}");
11960 }
11961 other => panic!(),
11962 }
11963 });
11964 }
11965
11966 #[test]
11967 fn protocol_dispatch_ui_success() {
11968 futures::executor::block_on(async {
11969 let runtime = Rc::new(
11970 PiJsRuntime::with_clock(DeterministicClock::new(0))
11971 .await
11972 .expect("runtime"),
11973 );
11974 let dispatcher = ExtensionDispatcher::new_with_policy(
11975 Rc::clone(&runtime),
11976 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
11977 Arc::new(HttpConnector::with_defaults()),
11978 Arc::new(NullSession),
11979 Arc::new(NullUiHandler),
11980 PathBuf::from("."),
11981 ExtensionPolicy::from_profile(PolicyProfile::Permissive),
11982 );
11983
11984 let message = ExtensionMessage {
11985 id: "msg-ui-proto".to_string(),
11986 version: PROTOCOL_VERSION.to_string(),
11987 body: ExtensionBody::HostCall(HostCallPayload {
11988 call_id: "call-ui-proto".to_string(),
11989 capability: "ui".to_string(),
11990 method: "ui".to_string(),
11991 params: serde_json::json!({ "op": "notification", "message": "test" }),
11992 timeout_ms: None,
11993 cancel_token: None,
11994 context: None,
11995 }),
11996 };
11997
11998 let response = dispatcher
11999 .dispatch_protocol_message(message)
12000 .await
12001 .expect("protocol ui dispatch");
12002
12003 match response.body {
12004 ExtensionBody::HostResult(result) => {
12005 assert!(!result.is_error, "expected success: {result:?}");
12006 }
12007 other => panic!(),
12008 }
12009 });
12010 }
12011
12012 #[test]
12013 fn protocol_dispatch_ui_missing_op_returns_error() {
12014 futures::executor::block_on(async {
12015 let runtime = Rc::new(
12016 PiJsRuntime::with_clock(DeterministicClock::new(0))
12017 .await
12018 .expect("runtime"),
12019 );
12020 let dispatcher = build_dispatcher(Rc::clone(&runtime));
12021
12022 let message = ExtensionMessage {
12023 id: "msg-ui-noop".to_string(),
12024 version: PROTOCOL_VERSION.to_string(),
12025 body: ExtensionBody::HostCall(HostCallPayload {
12026 call_id: "call-ui-noop".to_string(),
12027 capability: "ui".to_string(),
12028 method: "ui".to_string(),
12029 params: serde_json::json!({ "message": "test" }),
12030 timeout_ms: None,
12031 cancel_token: None,
12032 context: None,
12033 }),
12034 };
12035
12036 let response = dispatcher
12037 .dispatch_protocol_message(message)
12038 .await
12039 .expect("protocol dispatch");
12040
12041 match response.body {
12042 ExtensionBody::HostResult(result) => {
12043 assert!(result.is_error);
12044 let error = result.error.expect("error");
12045 assert_eq!(error.code, HostCallErrorCode::InvalidRequest);
12046 assert!(
12047 error.message.contains("op"),
12048 "error should mention 'op': {}",
12049 error.message
12050 );
12051 }
12052 other => panic!(),
12053 }
12054 });
12055 }
12056
12057 #[test]
12058 fn protocol_dispatch_events_missing_op_returns_error() {
12059 futures::executor::block_on(async {
12060 let runtime = Rc::new(
12061 PiJsRuntime::with_clock(DeterministicClock::new(0))
12062 .await
12063 .expect("runtime"),
12064 );
12065 let dispatcher = build_dispatcher(Rc::clone(&runtime));
12066
12067 let message = ExtensionMessage {
12068 id: "msg-events-noop".to_string(),
12069 version: PROTOCOL_VERSION.to_string(),
12070 body: ExtensionBody::HostCall(HostCallPayload {
12071 call_id: "call-events-noop".to_string(),
12072 capability: "events".to_string(),
12073 method: "events".to_string(),
12074 params: serde_json::json!({ "data": {} }),
12075 timeout_ms: None,
12076 cancel_token: None,
12077 context: None,
12078 }),
12079 };
12080
12081 let response = dispatcher
12082 .dispatch_protocol_message(message)
12083 .await
12084 .expect("protocol dispatch");
12085
12086 match response.body {
12087 ExtensionBody::HostResult(result) => {
12088 assert!(result.is_error);
12089 let error = result.error.expect("error");
12090 assert_eq!(error.code, HostCallErrorCode::InvalidRequest);
12091 assert!(
12092 error.message.contains("op"),
12093 "error should mention 'op': {}",
12094 error.message
12095 );
12096 }
12097 other => panic!(),
12098 }
12099 });
12100 }
12101
12102 #[test]
12103 fn protocol_dispatch_log_returns_success() {
12104 futures::executor::block_on(async {
12105 let runtime = Rc::new(
12106 PiJsRuntime::with_clock(DeterministicClock::new(0))
12107 .await
12108 .expect("runtime"),
12109 );
12110 let dispatcher = build_dispatcher(Rc::clone(&runtime));
12111
12112 let message = ExtensionMessage {
12113 id: "msg-log-proto".to_string(),
12114 version: PROTOCOL_VERSION.to_string(),
12115 body: ExtensionBody::HostCall(HostCallPayload {
12116 call_id: "call-log-proto".to_string(),
12117 capability: "log".to_string(),
12118 method: "log".to_string(),
12119 params: serde_json::json!({ "message": "test log" }),
12120 timeout_ms: None,
12121 cancel_token: None,
12122 context: None,
12123 }),
12124 };
12125
12126 let response = dispatcher
12127 .dispatch_protocol_message(message)
12128 .await
12129 .expect("protocol log dispatch");
12130
12131 match response.body {
12132 ExtensionBody::HostResult(result) => {
12133 assert!(!result.is_error, "log dispatch should succeed: {result:?}");
12134 }
12135 other => panic!(),
12136 }
12137 });
12138 }
12139
12140 fn regime_signal(
12141 queue_depth: f64,
12142 service_time_us: f64,
12143 opcode_entropy: f64,
12144 llc_miss_rate: f64,
12145 ) -> RegimeSignal {
12146 RegimeSignal {
12147 queue_depth,
12148 service_time_us,
12149 opcode_entropy,
12150 llc_miss_rate,
12151 }
12152 }
12153
12154 fn drive_detector_to_interleaved(detector: &mut RegimeShiftDetector) {
12155 for _ in 0..64 {
12156 let _ = detector.observe(regime_signal(1.0, 600.0, 0.8, 0.02));
12157 }
12158 for _ in 0..48 {
12159 let observation = detector.observe(regime_signal(40.0, 14_000.0, 2.6, 0.92));
12160 if observation.transition == Some(RegimeTransition::EnterInterleavedBatching) {
12161 break;
12162 }
12163 }
12164 }
12165
12166 #[test]
12167 fn regime_detector_switches_to_interleaved_on_sustained_upshift() {
12168 let mut detector = RegimeShiftDetector::default();
12169 let mut switched = false;
12170
12171 for _ in 0..64 {
12172 let _ = detector.observe(regime_signal(1.0, 700.0, 0.9, 0.03));
12173 }
12174 for _ in 0..64 {
12175 let observation = detector.observe(regime_signal(42.0, 16_000.0, 2.8, 0.95));
12176 if observation.transition == Some(RegimeTransition::EnterInterleavedBatching) {
12177 switched = true;
12178 break;
12179 }
12180 }
12181
12182 assert!(
12183 switched,
12184 "detector should switch on sustained high-contention shift"
12185 );
12186 assert_eq!(
12187 detector.current_mode(),
12188 RegimeAdaptationMode::InterleavedBatching
12189 );
12190 }
12191
12192 #[test]
12193 fn regime_detector_avoids_false_positives_on_stationary_noise() {
12194 let mut detector = RegimeShiftDetector::default();
12195 let mut transitions = 0_usize;
12196
12197 for idx in 0..320 {
12198 let jitter = match idx % 5 {
12199 0 => -70.0,
12200 1 => -20.0,
12201 2 => 0.0,
12202 3 => 35.0,
12203 _ => 80.0,
12204 };
12205 let queue_depth = if idx % 3 == 0 { 2.0 } else { 1.0 };
12206 let entropy = if idx % 7 == 0 { 1.2 } else { 1.0 };
12207 let observation =
12208 detector.observe(regime_signal(queue_depth, 900.0 + jitter, entropy, 0.06));
12209 if observation.transition.is_some() {
12210 transitions = transitions.saturating_add(1);
12211 }
12212 }
12213
12214 assert_eq!(
12215 transitions, 0,
12216 "stationary noise should not trigger transitions"
12217 );
12218 assert_eq!(
12219 detector.current_mode(),
12220 RegimeAdaptationMode::SequentialFastPath
12221 );
12222 }
12223
12224 #[test]
12225 fn regime_detector_hysteresis_limits_thrash() {
12226 let mut detector = RegimeShiftDetector::default();
12227 drive_detector_to_interleaved(&mut detector);
12228 assert_eq!(
12229 detector.current_mode(),
12230 RegimeAdaptationMode::InterleavedBatching
12231 );
12232
12233 let mut transitions = 0_usize;
12234 for idx in 0..200 {
12235 let signal = if idx % 2 == 0 {
12236 regime_signal(36.0, 12_500.0, 2.4, 0.88)
12237 } else {
12238 regime_signal(5.0, 2_200.0, 1.1, 0.18)
12239 };
12240 let observation = detector.observe(signal);
12241 if observation.transition.is_some() {
12242 transitions = transitions.saturating_add(1);
12243 }
12244 }
12245
12246 assert!(
12247 transitions <= 5,
12248 "hysteresis/cooldown should prevent oscillation: observed {transitions} transitions"
12249 );
12250 }
12251
12252 #[test]
12253 fn regime_detector_fallbacks_when_workload_cools() {
12254 let mut detector = RegimeShiftDetector::default();
12255 drive_detector_to_interleaved(&mut detector);
12256 assert_eq!(
12257 detector.current_mode(),
12258 RegimeAdaptationMode::InterleavedBatching
12259 );
12260
12261 let mut fallback_triggered = false;
12262 let mut returned_to_sequential = false;
12263 for _ in 0..40 {
12264 let observation = detector.observe(regime_signal(0.0, 450.0, 0.2, 0.0));
12265 if observation.fallback_triggered {
12266 fallback_triggered = true;
12267 }
12268 if observation.transition == Some(RegimeTransition::ReturnToSequentialFastPath) {
12269 returned_to_sequential = true;
12270 }
12271 }
12272
12273 assert!(
12274 fallback_triggered,
12275 "low queue/latency should trigger conservative fallback"
12276 );
12277 assert!(
12278 returned_to_sequential,
12279 "fallback should report an explicit transition"
12280 );
12281 assert_eq!(
12282 detector.current_mode(),
12283 RegimeAdaptationMode::SequentialFastPath
12284 );
12285 }
12286
12287 #[test]
12288 fn rollout_gate_blocks_cherry_picked_high_contention_claims() {
12289 let mut detector = RegimeShiftDetector::default();
12290 let mut saw_block = false;
12291 let mut switched = false;
12292
12293 for _ in 0..160 {
12294 let observation = detector.observe(regime_signal(46.0, 17_500.0, 3.0, 0.95));
12295 if observation.rollout_blocked_cherry_picked {
12296 saw_block = true;
12297 }
12298 if observation.transition == Some(RegimeTransition::EnterInterleavedBatching) {
12299 switched = true;
12300 }
12301 }
12302
12303 assert!(saw_block, "gate should surface cherry-pick blocking signal");
12304 assert!(!switched, "high-only stream must not promote rollout");
12305 assert_eq!(
12306 detector.current_mode(),
12307 RegimeAdaptationMode::SequentialFastPath
12308 );
12309 }
12310
12311 #[test]
12312 fn rollout_gate_promotes_after_stratified_evidence_reaches_threshold() {
12313 let mut detector = RegimeShiftDetector::default();
12314 let mut promoted = false;
12315
12316 for _ in 0..80 {
12317 let _ = detector.observe(regime_signal(1.0, 700.0, 0.9, 0.03));
12318 }
12319 for _ in 0..96 {
12320 let observation = detector.observe(regime_signal(42.0, 16_000.0, 2.8, 0.95));
12321 if observation.transition == Some(RegimeTransition::EnterInterleavedBatching) {
12322 promoted = true;
12323 assert_eq!(
12324 observation.rollout_action,
12325 RolloutGateAction::PromoteInterleaved
12326 );
12327 assert!(
12328 observation.rollout_promote_e_process >= observation.rollout_evidence_threshold
12329 );
12330 assert!(observation.rollout_coverage_ready);
12331 assert!(
12332 observation.rollout_expected_loss.promote
12333 < observation.rollout_expected_loss.hold
12334 );
12335 break;
12336 }
12337 }
12338
12339 assert!(
12340 promoted,
12341 "stratified stream should promote interleaved batching"
12342 );
12343 assert_eq!(
12344 detector.current_mode(),
12345 RegimeAdaptationMode::InterleavedBatching
12346 );
12347 }
12348
12349 #[test]
12350 fn rollout_gate_rolls_back_after_stratified_regression_evidence() {
12351 let mut detector = RegimeShiftDetector::default();
12352 drive_detector_to_interleaved(&mut detector);
12353 assert_eq!(
12354 detector.current_mode(),
12355 RegimeAdaptationMode::InterleavedBatching
12356 );
12357
12358 let mut rolled_back = false;
12359 for _ in 0..320 {
12360 let observation = detector.observe(regime_signal(1.4, 1_500.0, 0.6, 0.02));
12361 if observation.transition == Some(RegimeTransition::ReturnToSequentialFastPath) {
12362 rolled_back = true;
12363 assert_eq!(
12364 observation.rollout_action,
12365 RolloutGateAction::RollbackSequential
12366 );
12367 assert!(
12368 observation.rollout_rollback_e_process
12369 >= observation.rollout_evidence_threshold
12370 );
12371 assert!(observation.rollout_coverage_ready);
12372 assert!(
12373 observation.rollout_expected_loss.rollback
12374 < observation.rollout_expected_loss.hold
12375 );
12376 break;
12377 }
12378 }
12379
12380 assert!(
12381 rolled_back,
12382 "low-contention regression stream should trigger rollout rollback"
12383 );
12384 assert_eq!(
12385 detector.current_mode(),
12386 RegimeAdaptationMode::SequentialFastPath
12387 );
12388 }
12389
12390 #[test]
12391 fn dual_exec_sampling_is_deterministic_for_same_request() {
12392 let request = HostcallRequest {
12393 call_id: "sample-deterministic".to_string(),
12394 kind: HostcallKind::Session {
12395 op: "get_state".to_string(),
12396 },
12397 payload: serde_json::json!({}),
12398 trace_id: 77,
12399 extension_id: Some("ext.det".to_string()),
12400 };
12401 let first = should_sample_shadow_dual_exec(&request, 100_000);
12402 for _ in 0..16 {
12403 assert_eq!(should_sample_shadow_dual_exec(&request, 100_000), first);
12404 }
12405 }
12406
12407 #[test]
12408 fn dual_exec_sampling_respects_zero_and_full_scale_boundaries() {
12409 let request = HostcallRequest {
12410 call_id: "sample-boundary".to_string(),
12411 kind: HostcallKind::Session {
12412 op: "get_state".to_string(),
12413 },
12414 payload: serde_json::json!({}),
12415 trace_id: 91,
12416 extension_id: Some("ext.boundary".to_string()),
12417 };
12418
12419 assert!(!should_sample_shadow_dual_exec(&request, 0));
12420 assert!(should_sample_shadow_dual_exec(
12421 &request,
12422 DUAL_EXEC_SAMPLE_MODULUS_PPM
12423 ));
12424 assert!(should_sample_shadow_dual_exec(
12425 &request,
12426 DUAL_EXEC_SAMPLE_MODULUS_PPM.saturating_add(1)
12427 ));
12428 }
12429
12430 #[test]
12431 fn normalized_shadow_op_is_deterministic_across_format_variants() {
12432 assert_eq!(normalized_shadow_op(" get__state "), "getstate");
12433 assert_eq!(normalized_shadow_op("GET_STATE"), "getstate");
12434 assert_eq!(normalized_shadow_op("GeT_sTaTe"), "getstate");
12435 assert_eq!(normalized_shadow_op("list_flags"), "listflags");
12436 }
12437
12438 #[test]
12439 fn shadow_safe_classification_accepts_normalized_read_only_ops() {
12440 let session_request = HostcallRequest {
12441 call_id: "shadow-safe-session".to_string(),
12442 kind: HostcallKind::Session {
12443 op: " GET__MESSAGES ".to_string(),
12444 },
12445 payload: serde_json::json!({}),
12446 trace_id: 5,
12447 extension_id: Some("ext.shadow.safe".to_string()),
12448 };
12449 let events_request = HostcallRequest {
12450 call_id: "shadow-safe-events".to_string(),
12451 kind: HostcallKind::Events {
12452 op: " list_flags ".to_string(),
12453 },
12454 payload: serde_json::json!({}),
12455 trace_id: 6,
12456 extension_id: Some("ext.shadow.safe".to_string()),
12457 };
12458 let tool_request = HostcallRequest {
12459 call_id: "shadow-safe-tool".to_string(),
12460 kind: HostcallKind::Tool {
12461 name: " read ".to_string(),
12462 },
12463 payload: serde_json::json!({}),
12464 trace_id: 7,
12465 extension_id: Some("ext.shadow.safe".to_string()),
12466 };
12467
12468 assert!(is_shadow_safe_request(&session_request));
12469 assert!(is_shadow_safe_request(&events_request));
12470 assert!(is_shadow_safe_request(&tool_request));
12471 }
12472
12473 #[test]
12474 fn shadow_safe_classification_rejects_mutating_and_unsafe_kinds() {
12475 let requests = [
12476 (
12477 "session mutate",
12478 HostcallRequest {
12479 call_id: "shadow-unsafe-session".to_string(),
12480 kind: HostcallKind::Session {
12481 op: "append_message".to_string(),
12482 },
12483 payload: serde_json::json!({}),
12484 trace_id: 11,
12485 extension_id: Some("ext.shadow.unsafe".to_string()),
12486 },
12487 ),
12488 (
12489 "events mutate",
12490 HostcallRequest {
12491 call_id: "shadow-unsafe-events".to_string(),
12492 kind: HostcallKind::Events {
12493 op: "set_flag".to_string(),
12494 },
12495 payload: serde_json::json!({}),
12496 trace_id: 12,
12497 extension_id: Some("ext.shadow.unsafe".to_string()),
12498 },
12499 ),
12500 (
12501 "tool mutate",
12502 HostcallRequest {
12503 call_id: "shadow-unsafe-tool".to_string(),
12504 kind: HostcallKind::Tool {
12505 name: "write".to_string(),
12506 },
12507 payload: serde_json::json!({}),
12508 trace_id: 13,
12509 extension_id: Some("ext.shadow.unsafe".to_string()),
12510 },
12511 ),
12512 (
12513 "exec",
12514 HostcallRequest {
12515 call_id: "shadow-unsafe-exec".to_string(),
12516 kind: HostcallKind::Exec {
12517 cmd: "echo nope".to_string(),
12518 },
12519 payload: serde_json::json!({}),
12520 trace_id: 14,
12521 extension_id: Some("ext.shadow.unsafe".to_string()),
12522 },
12523 ),
12524 (
12525 "http",
12526 HostcallRequest {
12527 call_id: "shadow-unsafe-http".to_string(),
12528 kind: HostcallKind::Http,
12529 payload: serde_json::json!({}),
12530 trace_id: 15,
12531 extension_id: Some("ext.shadow.unsafe".to_string()),
12532 },
12533 ),
12534 (
12535 "ui",
12536 HostcallRequest {
12537 call_id: "shadow-unsafe-ui".to_string(),
12538 kind: HostcallKind::Ui {
12539 op: "prompt".to_string(),
12540 },
12541 payload: serde_json::json!({}),
12542 trace_id: 16,
12543 extension_id: Some("ext.shadow.unsafe".to_string()),
12544 },
12545 ),
12546 (
12547 "log",
12548 HostcallRequest {
12549 call_id: "shadow-unsafe-log".to_string(),
12550 kind: HostcallKind::Log,
12551 payload: serde_json::json!({}),
12552 trace_id: 17,
12553 extension_id: Some("ext.shadow.unsafe".to_string()),
12554 },
12555 ),
12556 ];
12557
12558 for (case, request) in &requests {
12559 assert!(
12560 !is_shadow_safe_request(request),
12561 "expected non-shadow-safe classification for {case}"
12562 );
12563 }
12564 }
12565
12566 #[test]
12567 fn dual_exec_diff_engine_detects_success_output_mismatch() {
12568 let fast = HostcallOutcome::Success(serde_json::json!({ "value": 1 }));
12569 let compat = HostcallOutcome::Success(serde_json::json!({ "value": 2 }));
12570 let diff = diff_hostcall_outcomes(&fast, &compat).expect("expected diff");
12571 assert_eq!(diff.reason, "success_output_mismatch");
12572 assert_ne!(diff.fast_fingerprint, diff.compat_fingerprint);
12573 }
12574
12575 #[test]
12576 fn dual_exec_forensic_bundle_includes_trace_lane_diff_and_rollback_fields() {
12577 let request = HostcallRequest {
12578 call_id: "forensic-1".to_string(),
12579 kind: HostcallKind::Session {
12580 op: "get_state".to_string(),
12581 },
12582 payload: serde_json::json!({ "op": "get_state" }),
12583 trace_id: 9,
12584 extension_id: Some("ext.forensic".to_string()),
12585 };
12586 let diff = DualExecOutcomeDiff {
12587 reason: "success_output_mismatch",
12588 fast_fingerprint: "success:aaa".to_string(),
12589 compat_fingerprint: "success:bbb".to_string(),
12590 };
12591 let bundle = dual_exec_forensic_bundle(
12592 &request,
12593 &diff,
12594 Some("forced_compat_budget_controller"),
12595 42.0,
12596 );
12597 assert_eq!(
12598 bundle["call_trace"]["call_id"],
12599 Value::String("forensic-1".to_string())
12600 );
12601 assert_eq!(
12602 bundle["lane_decision"]["fast_lane"],
12603 Value::String("fast".to_string())
12604 );
12605 assert_eq!(
12606 bundle["lane_decision"]["compat_lane"],
12607 Value::String("compat_shadow".to_string())
12608 );
12609 assert_eq!(
12610 bundle["diff"]["reason"],
12611 Value::String("success_output_mismatch".to_string())
12612 );
12613 assert_eq!(
12614 bundle["rollback"]["reason"],
12615 Value::String("forced_compat_budget_controller".to_string())
12616 );
12617 }
12618
12619 #[test]
12620 #[allow(clippy::too_many_lines)]
12621 fn dual_exec_divergence_auto_triggers_rollback_kill_switch_state() {
12622 futures::executor::block_on(async {
12623 struct DivergentReadSession {
12624 counter: Arc<Mutex<u64>>,
12625 }
12626
12627 #[async_trait]
12628 impl ExtensionSession for DivergentReadSession {
12629 async fn get_state(&self) -> Value {
12630 let mut guard = self
12631 .counter
12632 .lock()
12633 .unwrap_or_else(std::sync::PoisonError::into_inner);
12634 let value = *guard;
12635 *guard = guard.saturating_add(1);
12636 drop(guard);
12637 serde_json::json!({ "seq": value })
12638 }
12639
12640 async fn get_messages(&self) -> Vec<SessionMessage> {
12641 Vec::new()
12642 }
12643
12644 async fn get_entries(&self) -> Vec<Value> {
12645 Vec::new()
12646 }
12647
12648 async fn get_branch(&self) -> Vec<Value> {
12649 Vec::new()
12650 }
12651
12652 async fn set_name(&self, _name: String) -> Result<()> {
12653 Ok(())
12654 }
12655
12656 async fn append_message(&self, _message: SessionMessage) -> Result<()> {
12657 Ok(())
12658 }
12659
12660 async fn append_custom_entry(
12661 &self,
12662 _custom_type: String,
12663 _data: Option<Value>,
12664 ) -> Result<()> {
12665 Ok(())
12666 }
12667
12668 async fn set_model(&self, _provider: String, _model_id: String) -> Result<()> {
12669 Ok(())
12670 }
12671
12672 async fn get_model(&self) -> (Option<String>, Option<String>) {
12673 (None, None)
12674 }
12675
12676 async fn set_thinking_level(&self, _level: String) -> Result<()> {
12677 Ok(())
12678 }
12679
12680 async fn get_thinking_level(&self) -> Option<String> {
12681 None
12682 }
12683
12684 async fn set_label(
12685 &self,
12686 _target_id: String,
12687 _label: Option<String>,
12688 ) -> Result<()> {
12689 Ok(())
12690 }
12691 }
12692
12693 let runtime = Rc::new(
12694 PiJsRuntime::with_clock(DeterministicClock::new(0))
12695 .await
12696 .expect("runtime"),
12697 );
12698 let session = Arc::new(DivergentReadSession {
12699 counter: Arc::new(Mutex::new(0)),
12700 });
12701 let oracle_config = DualExecOracleConfig {
12702 sample_ppm: DUAL_EXEC_SAMPLE_MODULUS_PPM,
12703 divergence_window: 4,
12704 divergence_budget: 2,
12705 rollback_requests: 24,
12706 overhead_budget_us: u64::MAX,
12707 overhead_backoff_requests: 1,
12708 };
12709 let dispatcher = ExtensionDispatcher::new_with_policy_and_oracle_config(
12710 Rc::clone(&runtime),
12711 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
12712 Arc::new(HttpConnector::with_defaults()),
12713 session,
12714 Arc::new(NullUiHandler),
12715 PathBuf::from("."),
12716 ExtensionPolicy::from_profile(PolicyProfile::Permissive),
12717 oracle_config,
12718 );
12719
12720 for idx in 0..3_u64 {
12721 let request = HostcallRequest {
12722 call_id: format!("dual-divergence-{idx}"),
12723 kind: HostcallKind::Session {
12724 op: "get_state".to_string(),
12725 },
12726 payload: serde_json::json!({}),
12727 trace_id: idx,
12728 extension_id: Some("ext.shadow.rollback".to_string()),
12729 };
12730 dispatcher.dispatch_and_complete(request).await;
12731 }
12732
12733 let state = dispatcher.dual_exec_state.borrow();
12734 assert!(
12735 state.divergence_total >= 2,
12736 "expected enough divergence samples to trip rollback"
12737 );
12738 assert!(state.rollback_active(), "rollback should be active");
12739 assert!(
12740 state
12741 .rollback_reason
12742 .as_deref()
12743 .is_some_and(|reason| reason.contains("ext.shadow.rollback")),
12744 "rollback reason should include extension scope"
12745 );
12746 });
12747 }
12748
12749 #[test]
12750 fn dual_exec_rollback_forces_dispatch_batch_amac_to_skip_planning() {
12751 futures::executor::block_on(async {
12752 let runtime = Rc::new(
12753 PiJsRuntime::with_clock(DeterministicClock::new(0))
12754 .await
12755 .expect("runtime"),
12756 );
12757 let oracle_config = DualExecOracleConfig {
12758 sample_ppm: 0,
12759 divergence_window: 8,
12760 divergence_budget: 2,
12761 rollback_requests: 16,
12762 overhead_budget_us: 1_500,
12763 overhead_backoff_requests: 8,
12764 };
12765 let dispatcher = ExtensionDispatcher::new_with_policy_and_oracle_config(
12766 Rc::clone(&runtime),
12767 Arc::new(ToolRegistry::new(&[], Path::new("."), None)),
12768 Arc::new(HttpConnector::with_defaults()),
12769 Arc::new(NullSession),
12770 Arc::new(NullUiHandler),
12771 PathBuf::from("."),
12772 ExtensionPolicy::from_profile(PolicyProfile::Permissive),
12773 oracle_config,
12774 );
12775
12776 {
12777 let mut amac = dispatcher.amac_executor.borrow_mut();
12778 *amac = AmacBatchExecutor::new(AmacBatchExecutorConfig::new(true, 2, 8));
12779 }
12780
12781 {
12782 let mut detector = dispatcher.regime_detector.borrow_mut();
12783 drive_detector_to_interleaved(&mut detector);
12784 }
12785
12786 let mut baseline = VecDeque::new();
12787 for idx in 0..4_u64 {
12788 baseline.push_back(HostcallRequest {
12789 call_id: format!("baseline-{idx}"),
12790 kind: HostcallKind::Session {
12791 op: "get_state".to_string(),
12792 },
12793 payload: serde_json::json!({}),
12794 trace_id: idx,
12795 extension_id: Some("ext.roll".to_string()),
12796 });
12797 }
12798 dispatcher.dispatch_batch_amac(baseline).await;
12799 let baseline_decisions = dispatcher
12800 .amac_executor
12801 .borrow()
12802 .telemetry()
12803 .toggle_decisions;
12804 assert!(
12805 baseline_decisions > 0,
12806 "expected AMAC planner to run before rollback activation"
12807 );
12808
12809 {
12810 let mut state = dispatcher.dual_exec_state.borrow_mut();
12811 state.rollback_remaining = 16;
12812 state.rollback_reason =
12813 Some("dual_exec_divergence_budget_exceeded:test".to_string());
12814 }
12815
12816 let mut rollback_batch = VecDeque::new();
12817 for idx in 0..4_u64 {
12818 rollback_batch.push_back(HostcallRequest {
12819 call_id: format!("rollback-{idx}"),
12820 kind: HostcallKind::Session {
12821 op: "get_state".to_string(),
12822 },
12823 payload: serde_json::json!({}),
12824 trace_id: idx + 100,
12825 extension_id: Some("ext.roll".to_string()),
12826 });
12827 }
12828 dispatcher.dispatch_batch_amac(rollback_batch).await;
12829
12830 let after_rollback = dispatcher
12831 .amac_executor
12832 .borrow()
12833 .telemetry()
12834 .toggle_decisions;
12835 assert_eq!(
12836 after_rollback, baseline_decisions,
12837 "rollback path should bypass AMAC planning and keep toggle decisions unchanged"
12838 );
12839 });
12840 }
12841
12842 #[test]
12843 fn rollout_mode_controls_amac_planner_activation() {
12844 futures::executor::block_on(async {
12845 let runtime = Rc::new(
12846 PiJsRuntime::with_clock(DeterministicClock::new(0))
12847 .await
12848 .expect("runtime"),
12849 );
12850 let dispatcher = build_dispatcher(Rc::clone(&runtime));
12851 {
12852 let mut amac = dispatcher.amac_executor.borrow_mut();
12853 *amac = AmacBatchExecutor::new(AmacBatchExecutorConfig::new(true, 2, 8));
12854 }
12855
12856 let mut sequential_batch = VecDeque::new();
12857 for idx in 0..4_u64 {
12858 sequential_batch.push_back(HostcallRequest {
12859 call_id: format!("rollout-seq-{idx}"),
12860 kind: HostcallKind::Session {
12861 op: "get_state".to_string(),
12862 },
12863 payload: serde_json::json!({}),
12864 trace_id: idx,
12865 extension_id: Some("ext.rollout.mode".to_string()),
12866 });
12867 }
12868 dispatcher.dispatch_batch_amac(sequential_batch).await;
12869 let decisions_after_seq = dispatcher
12870 .amac_executor
12871 .borrow()
12872 .telemetry()
12873 .toggle_decisions;
12874 assert_eq!(
12875 decisions_after_seq, 0,
12876 "sequential rollout mode should skip AMAC planning"
12877 );
12878
12879 {
12880 let mut detector = dispatcher.regime_detector.borrow_mut();
12881 drive_detector_to_interleaved(&mut detector);
12882 }
12883
12884 let mut interleaved_batch = VecDeque::new();
12885 for idx in 0..4_u64 {
12886 interleaved_batch.push_back(HostcallRequest {
12887 call_id: format!("rollout-interleaved-{idx}"),
12888 kind: HostcallKind::Session {
12889 op: "get_state".to_string(),
12890 },
12891 payload: serde_json::json!({}),
12892 trace_id: idx + 100,
12893 extension_id: Some("ext.rollout.mode".to_string()),
12894 });
12895 }
12896 dispatcher.dispatch_batch_amac(interleaved_batch).await;
12897 let decisions_after_interleaved = dispatcher
12898 .amac_executor
12899 .borrow()
12900 .telemetry()
12901 .toggle_decisions;
12902 assert!(
12903 decisions_after_interleaved > decisions_after_seq,
12904 "promotion should enable AMAC planning"
12905 );
12906 });
12907 }
12908
12909 #[test]
12910 fn hostcall_io_hint_marks_expected_kinds_as_io_heavy() {
12911 assert_eq!(
12912 hostcall_io_hint(&HostcallKind::Http),
12913 HostcallIoHint::IoHeavy
12914 );
12915 assert_eq!(
12916 hostcall_io_hint(&HostcallKind::Tool {
12917 name: "read".to_string()
12918 }),
12919 HostcallIoHint::IoHeavy
12920 );
12921 assert_eq!(
12922 hostcall_io_hint(&HostcallKind::Session {
12923 op: "append_message".to_string()
12924 }),
12925 HostcallIoHint::IoHeavy
12926 );
12927 }
12928
12929 #[test]
12930 fn hostcall_io_hint_marks_non_io_kinds_as_non_heavy() {
12931 assert_eq!(
12932 hostcall_io_hint(&HostcallKind::Ui {
12933 op: "prompt".to_string()
12934 }),
12935 HostcallIoHint::CpuBound
12936 );
12937 assert_eq!(
12938 hostcall_io_hint(&HostcallKind::Tool {
12939 name: "unknown_tool".to_string()
12940 }),
12941 HostcallIoHint::Unknown
12942 );
12943 assert_eq!(
12944 hostcall_io_hint(&HostcallKind::Session {
12945 op: "get_state".to_string()
12946 }),
12947 HostcallIoHint::Unknown
12948 );
12949 }
12950
12951 #[test]
12952 fn hostcall_io_hint_classifies_edit_bash_and_exec() {
12953 assert_eq!(
12954 hostcall_io_hint(&HostcallKind::Tool {
12955 name: "edit".to_string()
12956 }),
12957 HostcallIoHint::IoHeavy,
12958 "edit tool should be IoHeavy"
12959 );
12960 assert_eq!(
12961 hostcall_io_hint(&HostcallKind::Tool {
12962 name: "bash".to_string()
12963 }),
12964 HostcallIoHint::CpuBound,
12965 "bash tool should be CpuBound"
12966 );
12967 assert_eq!(
12968 hostcall_io_hint(&HostcallKind::Exec {
12969 cmd: "ls".to_string()
12970 }),
12971 HostcallIoHint::CpuBound,
12972 "exec hostcall should be CpuBound"
12973 );
12974 }
12975
12976 #[test]
12977 fn io_uring_bridge_reports_cancellation_when_request_not_pending() {
12978 futures::executor::block_on(async {
12979 let runtime = Rc::new(
12980 PiJsRuntime::with_clock(DeterministicClock::new(0))
12981 .await
12982 .expect("runtime"),
12983 );
12984 let dispatcher = build_dispatcher(Rc::clone(&runtime));
12985 let request = HostcallRequest {
12986 call_id: "cancelled-before-io-uring".to_string(),
12987 kind: HostcallKind::Http,
12988 payload: serde_json::json!({
12989 "url": "https://example.com",
12990 "method": "GET",
12991 }),
12992 trace_id: 1,
12993 extension_id: Some("ext.cancel".to_string()),
12994 };
12995 let bridge_dispatch = dispatcher.dispatch_hostcall_io_uring(&request).await;
12996 assert_eq!(
12997 bridge_dispatch.state,
12998 IoUringBridgeState::CancelledBeforeDispatch
12999 );
13000 assert_eq!(
13001 bridge_dispatch.fallback_reason,
13002 Some("cancelled_before_io_uring_dispatch")
13003 );
13004 match bridge_dispatch.outcome {
13005 HostcallOutcome::Error { code, message } => {
13006 assert_eq!(code, "cancelled");
13007 assert!(
13008 message.contains("cancelled before io_uring dispatch"),
13009 "unexpected cancellation message: {message}"
13010 );
13011 }
13012 other => panic!(),
13013 }
13014 });
13015 }
13016
13017 #[test]
13022 fn protocol_error_code_timeout_maps_correctly() {
13023 assert_eq!(protocol_error_code("timeout"), HostCallErrorCode::Timeout);
13024 }
13025
13026 #[test]
13027 fn protocol_error_code_denied_maps_correctly() {
13028 assert_eq!(protocol_error_code("denied"), HostCallErrorCode::Denied);
13029 }
13030
13031 #[test]
13032 fn protocol_error_code_io_maps_correctly() {
13033 assert_eq!(protocol_error_code("io"), HostCallErrorCode::Io);
13034 }
13035
13036 #[test]
13037 fn protocol_error_code_tool_error_maps_to_io() {
13038 assert_eq!(protocol_error_code("tool_error"), HostCallErrorCode::Io);
13039 }
13040
13041 #[test]
13042 fn protocol_error_code_invalid_request_maps_correctly() {
13043 assert_eq!(
13044 protocol_error_code("invalid_request"),
13045 HostCallErrorCode::InvalidRequest
13046 );
13047 }
13048
13049 #[test]
13050 fn protocol_error_code_completely_unknown_maps_to_internal() {
13051 assert_eq!(
13052 protocol_error_code("completely_unknown"),
13053 HostCallErrorCode::Internal
13054 );
13055 }
13056
13057 #[test]
13058 fn protocol_error_code_empty_string_maps_to_internal() {
13059 assert_eq!(protocol_error_code(""), HostCallErrorCode::Internal);
13060 }
13061
13062 #[test]
13063 fn protocol_error_code_whitespace_only_maps_to_internal() {
13064 assert_eq!(protocol_error_code(" "), HostCallErrorCode::Internal);
13065 }
13066
13067 #[test]
13068 fn protocol_error_code_case_insensitive_timeout() {
13069 assert_eq!(protocol_error_code("TIMEOUT"), HostCallErrorCode::Timeout);
13070 assert_eq!(protocol_error_code("Timeout"), HostCallErrorCode::Timeout);
13071 assert_eq!(protocol_error_code("TimeOut"), HostCallErrorCode::Timeout);
13072 }
13073
13074 #[test]
13075 fn protocol_error_code_case_insensitive_denied() {
13076 assert_eq!(protocol_error_code("DENIED"), HostCallErrorCode::Denied);
13077 assert_eq!(protocol_error_code("Denied"), HostCallErrorCode::Denied);
13078 }
13079
13080 #[test]
13081 fn protocol_error_code_case_insensitive_io() {
13082 assert_eq!(protocol_error_code("IO"), HostCallErrorCode::Io);
13083 assert_eq!(protocol_error_code("Io"), HostCallErrorCode::Io);
13084 assert_eq!(protocol_error_code("TOOL_ERROR"), HostCallErrorCode::Io);
13085 assert_eq!(protocol_error_code("Tool_Error"), HostCallErrorCode::Io);
13086 }
13087
13088 #[test]
13089 fn protocol_error_code_case_insensitive_invalid_request() {
13090 assert_eq!(
13091 protocol_error_code("INVALID_REQUEST"),
13092 HostCallErrorCode::InvalidRequest
13093 );
13094 assert_eq!(
13095 protocol_error_code("Invalid_Request"),
13096 HostCallErrorCode::InvalidRequest
13097 );
13098 }
13099
13100 #[test]
13101 fn protocol_error_code_trims_whitespace() {
13102 assert_eq!(
13103 protocol_error_code(" timeout "),
13104 HostCallErrorCode::Timeout
13105 );
13106 assert_eq!(protocol_error_code("\tdenied\n"), HostCallErrorCode::Denied);
13107 }
13108
13109 #[test]
13110 fn parse_protocol_hostcall_method_all_known_methods() {
13111 assert_eq!(
13112 parse_protocol_hostcall_method("tool"),
13113 Some(ProtocolHostcallMethod::Tool)
13114 );
13115 assert_eq!(
13116 parse_protocol_hostcall_method("exec"),
13117 Some(ProtocolHostcallMethod::Exec)
13118 );
13119 assert_eq!(
13120 parse_protocol_hostcall_method("http"),
13121 Some(ProtocolHostcallMethod::Http)
13122 );
13123 assert_eq!(
13124 parse_protocol_hostcall_method("session"),
13125 Some(ProtocolHostcallMethod::Session)
13126 );
13127 assert_eq!(
13128 parse_protocol_hostcall_method("ui"),
13129 Some(ProtocolHostcallMethod::Ui)
13130 );
13131 assert_eq!(
13132 parse_protocol_hostcall_method("events"),
13133 Some(ProtocolHostcallMethod::Events)
13134 );
13135 assert_eq!(
13136 parse_protocol_hostcall_method("log"),
13137 Some(ProtocolHostcallMethod::Log)
13138 );
13139 }
13140
13141 #[test]
13142 fn parse_protocol_hostcall_method_case_insensitive() {
13143 assert_eq!(
13144 parse_protocol_hostcall_method("TOOL"),
13145 Some(ProtocolHostcallMethod::Tool)
13146 );
13147 assert_eq!(
13148 parse_protocol_hostcall_method("Tool"),
13149 Some(ProtocolHostcallMethod::Tool)
13150 );
13151 assert_eq!(
13152 parse_protocol_hostcall_method("SESSION"),
13153 Some(ProtocolHostcallMethod::Session)
13154 );
13155 assert_eq!(
13156 parse_protocol_hostcall_method("Events"),
13157 Some(ProtocolHostcallMethod::Events)
13158 );
13159 }
13160
13161 #[test]
13162 fn parse_protocol_hostcall_method_trims_whitespace() {
13163 assert_eq!(
13164 parse_protocol_hostcall_method(" tool "),
13165 Some(ProtocolHostcallMethod::Tool)
13166 );
13167 assert_eq!(
13168 parse_protocol_hostcall_method("\texec\n"),
13169 Some(ProtocolHostcallMethod::Exec)
13170 );
13171 }
13172
13173 #[test]
13174 fn parse_protocol_hostcall_method_rejects_unknown() {
13175 assert_eq!(parse_protocol_hostcall_method("unknown"), None);
13176 assert_eq!(parse_protocol_hostcall_method("foobar"), None);
13177 assert_eq!(parse_protocol_hostcall_method("tools"), None);
13178 }
13179
13180 #[test]
13181 fn parse_protocol_hostcall_method_rejects_empty() {
13182 assert_eq!(parse_protocol_hostcall_method(""), None);
13183 assert_eq!(parse_protocol_hostcall_method(" "), None);
13184 }
13185
13186 #[test]
13187 fn protocol_normalize_output_preserves_objects() {
13188 let obj = serde_json::json!({"key": "value", "nested": {"a": 1}});
13189 let result = protocol_normalize_output(obj.clone());
13190 assert_eq!(result, obj);
13191 }
13192
13193 #[test]
13194 fn protocol_normalize_output_wraps_string() {
13195 let val = serde_json::json!("hello");
13196 let result = protocol_normalize_output(val);
13197 assert_eq!(result, serde_json::json!({"value": "hello"}));
13198 }
13199
13200 #[test]
13201 fn protocol_normalize_output_wraps_number() {
13202 let val = serde_json::json!(42);
13203 let result = protocol_normalize_output(val);
13204 assert_eq!(result, serde_json::json!({"value": 42}));
13205 }
13206
13207 #[test]
13208 fn protocol_normalize_output_wraps_bool() {
13209 let val = serde_json::json!(true);
13210 let result = protocol_normalize_output(val);
13211 assert_eq!(result, serde_json::json!({"value": true}));
13212 }
13213
13214 #[test]
13215 fn protocol_normalize_output_wraps_null() {
13216 let val = Value::Null;
13217 let result = protocol_normalize_output(val);
13218 assert_eq!(result, serde_json::json!({"value": null}));
13219 }
13220
13221 #[test]
13222 fn protocol_normalize_output_wraps_array() {
13223 let val = serde_json::json!([1, 2, 3]);
13224 let result = protocol_normalize_output(val);
13225 assert_eq!(result, serde_json::json!({"value": [1, 2, 3]}));
13226 }
13227
13228 #[test]
13229 fn protocol_normalize_output_preserves_empty_object() {
13230 let val = serde_json::json!({});
13231 let result = protocol_normalize_output(val.clone());
13232 assert_eq!(result, val);
13233 }
13234
13235 #[test]
13236 fn protocol_error_fallback_reason_denied() {
13237 assert_eq!(
13238 protocol_error_fallback_reason("tool", "denied"),
13239 "policy_denied"
13240 );
13241 assert_eq!(
13242 protocol_error_fallback_reason("exec", "DENIED"),
13243 "policy_denied"
13244 );
13245 }
13246
13247 #[test]
13248 fn protocol_error_fallback_reason_timeout() {
13249 assert_eq!(
13250 protocol_error_fallback_reason("tool", "timeout"),
13251 "handler_timeout"
13252 );
13253 }
13254
13255 #[test]
13256 fn protocol_error_fallback_reason_io() {
13257 assert_eq!(
13258 protocol_error_fallback_reason("tool", "io"),
13259 "handler_error"
13260 );
13261 assert_eq!(
13262 protocol_error_fallback_reason("exec", "tool_error"),
13263 "handler_error"
13264 );
13265 }
13266
13267 #[test]
13268 fn protocol_error_fallback_reason_invalid_request_known_method() {
13269 assert_eq!(
13270 protocol_error_fallback_reason("tool", "invalid_request"),
13271 "schema_validation_failed"
13272 );
13273 assert_eq!(
13274 protocol_error_fallback_reason("session", "invalid_request"),
13275 "schema_validation_failed"
13276 );
13277 }
13278
13279 #[test]
13280 fn protocol_error_fallback_reason_invalid_request_unknown_method() {
13281 assert_eq!(
13282 protocol_error_fallback_reason("nonexistent", "invalid_request"),
13283 "unsupported_method_fallback"
13284 );
13285 }
13286
13287 #[test]
13288 fn protocol_error_fallback_reason_unknown_code() {
13289 assert_eq!(
13290 protocol_error_fallback_reason("tool", "something_else"),
13291 "runtime_internal_error"
13292 );
13293 assert_eq!(
13294 protocol_error_fallback_reason("tool", ""),
13295 "runtime_internal_error"
13296 );
13297 }
13298
13299 #[test]
13300 fn protocol_error_details_structure_complete() {
13301 let payload = HostCallPayload {
13302 call_id: "test-call-1".to_string(),
13303 capability: "tool".to_string(),
13304 method: "tool".to_string(),
13305 params: serde_json::json!({"name": "read", "input": {"path": "/tmp/test"}}),
13306 timeout_ms: None,
13307 cancel_token: None,
13308 context: None,
13309 };
13310
13311 let details = protocol_error_details(&payload, "invalid_request", "Tool not found");
13312
13313 assert!(details.get("dispatcherDecisionTrace").is_some());
13315 assert!(details.get("schemaDiff").is_some());
13316 assert!(details.get("extensionInput").is_some());
13317 assert!(details.get("extensionOutput").is_some());
13318
13319 let trace = &details["dispatcherDecisionTrace"];
13321 assert_eq!(trace["selectedRuntime"], "rust-extension-dispatcher");
13322 assert_eq!(trace["schemaVersion"], PROTOCOL_VERSION);
13323 assert_eq!(trace["method"], "tool");
13324 assert_eq!(trace["capability"], "tool");
13325 assert_eq!(trace["fallbackReason"], "schema_validation_failed");
13326
13327 let observed_keys = details["schemaDiff"]["observedParamKeys"]
13329 .as_array()
13330 .expect("observedParamKeys must be array");
13331 let keys: Vec<&str> = observed_keys.iter().filter_map(|v| v.as_str()).collect();
13332 assert_eq!(keys, vec!["input", "name"]);
13333
13334 assert_eq!(details["extensionInput"]["callId"], "test-call-1");
13336 assert_eq!(details["extensionInput"]["capability"], "tool");
13337 assert_eq!(details["extensionInput"]["method"], "tool");
13338
13339 assert_eq!(details["extensionOutput"]["code"], "invalid_request");
13341 assert_eq!(details["extensionOutput"]["message"], "Tool not found");
13342 }
13343
13344 #[test]
13345 fn protocol_error_details_non_object_params_yields_empty_keys() {
13346 let payload = HostCallPayload {
13347 call_id: "test-call-2".to_string(),
13348 capability: "exec".to_string(),
13349 method: "exec".to_string(),
13350 params: serde_json::json!("not an object"),
13351 timeout_ms: None,
13352 cancel_token: None,
13353 context: None,
13354 };
13355
13356 let details = protocol_error_details(&payload, "io", "exec failed");
13357 let observed_keys = details["schemaDiff"]["observedParamKeys"]
13358 .as_array()
13359 .expect("must be array");
13360 assert!(observed_keys.is_empty());
13361 assert_eq!(
13362 details["dispatcherDecisionTrace"]["fallbackReason"],
13363 "handler_error"
13364 );
13365 }
13366
13367 #[test]
13368 fn protocol_hostcall_op_extracts_from_op_key() {
13369 let params = serde_json::json!({"op": "getState"});
13370 assert_eq!(protocol_hostcall_op(¶ms), Some("getState"));
13371 }
13372
13373 #[test]
13374 fn protocol_hostcall_op_extracts_from_method_key() {
13375 let params = serde_json::json!({"method": "setModel"});
13376 assert_eq!(protocol_hostcall_op(¶ms), Some("setModel"));
13377 }
13378
13379 #[test]
13380 fn protocol_hostcall_op_extracts_from_name_key() {
13381 let params = serde_json::json!({"name": "read"});
13382 assert_eq!(protocol_hostcall_op(¶ms), Some("read"));
13383 }
13384
13385 #[test]
13386 fn protocol_hostcall_op_prefers_op_over_method() {
13387 let params = serde_json::json!({"op": "first", "method": "second"});
13388 assert_eq!(protocol_hostcall_op(¶ms), Some("first"));
13389 }
13390
13391 #[test]
13392 fn protocol_hostcall_op_prefers_method_over_name() {
13393 let params = serde_json::json!({"method": "first", "name": "second"});
13394 assert_eq!(protocol_hostcall_op(¶ms), Some("first"));
13395 }
13396
13397 #[test]
13398 fn protocol_hostcall_op_returns_none_for_empty_params() {
13399 let params = serde_json::json!({});
13400 assert_eq!(protocol_hostcall_op(¶ms), None);
13401 }
13402
13403 #[test]
13404 fn protocol_hostcall_op_returns_none_for_empty_string_value() {
13405 let params = serde_json::json!({"op": ""});
13406 assert_eq!(protocol_hostcall_op(¶ms), None);
13407 }
13408
13409 #[test]
13410 fn protocol_hostcall_op_returns_none_for_whitespace_only_value() {
13411 let params = serde_json::json!({"op": " "});
13412 assert_eq!(protocol_hostcall_op(¶ms), None);
13413 }
13414
13415 #[test]
13416 fn protocol_hostcall_op_trims_result() {
13417 let params = serde_json::json!({"op": " getState "});
13418 assert_eq!(protocol_hostcall_op(¶ms), Some("getState"));
13419 }
13420
13421 #[test]
13422 fn protocol_hostcall_op_returns_none_for_non_string_value() {
13423 let params = serde_json::json!({"op": 42});
13424 assert_eq!(protocol_hostcall_op(¶ms), None);
13425 }
13426
13427 #[test]
13428 fn hostcall_outcome_to_protocol_result_success_normalizes_output() {
13429 let result = hostcall_outcome_to_protocol_result(
13430 "call-s1",
13431 HostcallOutcome::Success(serde_json::json!({"result": "ok"})),
13432 );
13433 assert_eq!(result.call_id, "call-s1");
13434 assert!(!result.is_error);
13435 assert!(result.error.is_none());
13436 assert!(result.chunk.is_none());
13437 assert_eq!(result.output, serde_json::json!({"result": "ok"}));
13438 }
13439
13440 #[test]
13441 fn hostcall_outcome_to_protocol_result_success_wraps_plain_string() {
13442 let result = hostcall_outcome_to_protocol_result(
13443 "call-s2",
13444 HostcallOutcome::Success(serde_json::json!("plain string")),
13445 );
13446 assert_eq!(result.output, serde_json::json!({"value": "plain string"}));
13447 }
13448
13449 #[test]
13450 fn hostcall_outcome_to_protocol_result_error_maps_code() {
13451 let result = hostcall_outcome_to_protocol_result(
13452 "call-e1",
13453 HostcallOutcome::Error {
13454 code: "denied".to_string(),
13455 message: "not allowed".to_string(),
13456 },
13457 );
13458 assert_eq!(result.call_id, "call-e1");
13459 assert!(result.is_error);
13460 let err = result.error.as_ref().expect("error payload");
13461 assert_eq!(err.code, HostCallErrorCode::Denied);
13462 assert_eq!(err.message, "not allowed");
13463 assert!(err.details.is_none());
13464 assert!(result.output.is_object());
13465 }
13466
13467 #[test]
13468 fn hostcall_outcome_to_protocol_result_error_unknown_code_maps_internal() {
13469 let result = hostcall_outcome_to_protocol_result(
13470 "call-e2",
13471 HostcallOutcome::Error {
13472 code: "mystery_error".to_string(),
13473 message: "something broke".to_string(),
13474 },
13475 );
13476 let err = result.error.as_ref().expect("error payload");
13477 assert_eq!(err.code, HostCallErrorCode::Internal);
13478 }
13479
13480 #[test]
13481 fn hostcall_outcome_to_protocol_result_stream_partial_chunk() {
13482 let result = hostcall_outcome_to_protocol_result(
13483 "call-sc1",
13484 HostcallOutcome::StreamChunk {
13485 sequence: 5,
13486 chunk: serde_json::json!({"data": "partial"}),
13487 is_final: false,
13488 },
13489 );
13490 assert_eq!(result.call_id, "call-sc1");
13491 assert!(!result.is_error);
13492 assert!(result.error.is_none());
13493 let chunk = result.chunk.as_ref().expect("chunk metadata");
13494 assert_eq!(chunk.index, 5);
13495 assert!(!chunk.is_last);
13496 assert_eq!(result.output["sequence"], 5);
13497 assert_eq!(result.output["isFinal"], false);
13498 }
13499
13500 #[test]
13501 fn hostcall_outcome_to_protocol_result_stream_final_chunk() {
13502 let result = hostcall_outcome_to_protocol_result(
13503 "call-sc2",
13504 HostcallOutcome::StreamChunk {
13505 sequence: 10,
13506 chunk: serde_json::json!(null),
13507 is_final: true,
13508 },
13509 );
13510 let chunk = result.chunk.as_ref().expect("chunk metadata");
13511 assert!(chunk.is_last);
13512 assert_eq!(result.output["isFinal"], true);
13513 }
13514
13515 #[test]
13516 fn hostcall_outcome_to_protocol_result_with_trace_error_includes_details() {
13517 let payload = HostCallPayload {
13518 call_id: "call-trace-1".to_string(),
13519 capability: "tool".to_string(),
13520 method: "tool".to_string(),
13521 params: serde_json::json!({"name": "read"}),
13522 timeout_ms: None,
13523 cancel_token: None,
13524 context: None,
13525 };
13526
13527 let result = hostcall_outcome_to_protocol_result_with_trace(
13528 &payload,
13529 HostcallOutcome::Error {
13530 code: "timeout".to_string(),
13531 message: "operation timed out".to_string(),
13532 },
13533 );
13534
13535 assert!(result.is_error);
13536 let err = result.error.as_ref().expect("error");
13537 assert_eq!(err.code, HostCallErrorCode::Timeout);
13538 assert_eq!(err.message, "operation timed out");
13539
13540 let details = err.details.as_ref().expect("details must be present");
13542 assert!(details.get("dispatcherDecisionTrace").is_some());
13543 assert_eq!(
13544 details["dispatcherDecisionTrace"]["fallbackReason"],
13545 "handler_timeout"
13546 );
13547 }
13548
13549 #[test]
13550 fn hostcall_outcome_to_protocol_result_with_trace_success_no_details() {
13551 let payload = HostCallPayload {
13552 call_id: "call-trace-2".to_string(),
13553 capability: "tool".to_string(),
13554 method: "tool".to_string(),
13555 params: serde_json::json!({"name": "read"}),
13556 timeout_ms: None,
13557 cancel_token: None,
13558 context: None,
13559 };
13560
13561 let result = hostcall_outcome_to_protocol_result_with_trace(
13562 &payload,
13563 HostcallOutcome::Success(serde_json::json!({"content": "file data"})),
13564 );
13565
13566 assert!(!result.is_error);
13567 assert!(result.error.is_none());
13568 assert_eq!(result.output["content"], "file data");
13569 }
13570
13571 mod proptest_dispatcher {
13574 use super::*;
13575 use proptest::prelude::*;
13576
13577 proptest! {
13578 #[test]
13579 fn shannon_entropy_nonnegative(bytes in prop::collection::vec(any::<u8>(), 0..200)) {
13580 let entropy = shannon_entropy_bytes(&bytes);
13581 assert!(
13582 entropy >= 0.0,
13583 "entropy must be non-negative, got {entropy}"
13584 );
13585 }
13586
13587 #[test]
13588 fn shannon_entropy_bounded_by_log2_256(
13589 bytes in prop::collection::vec(any::<u8>(), 1..200),
13590 ) {
13591 let entropy = shannon_entropy_bytes(&bytes);
13592 assert!(
13593 entropy <= 8.0 + f64::EPSILON,
13594 "entropy must be <= 8.0 (log2(256)), got {entropy}"
13595 );
13596 }
13597
13598 #[test]
13599 fn shannon_entropy_empty_is_zero(_dummy in Just(())) {
13600 assert!(
13601 (shannon_entropy_bytes(&[]) - 0.0).abs() < f64::EPSILON,
13602 "entropy of empty input must be 0.0"
13603 );
13604 }
13605
13606 #[test]
13607 fn shannon_entropy_single_byte_is_zero(byte in any::<u8>()) {
13608 let entropy = shannon_entropy_bytes(&[byte]);
13609 assert!(
13610 entropy.abs() < f64::EPSILON,
13611 "entropy of single byte must be 0.0, got {entropy}"
13612 );
13613 }
13614
13615 #[test]
13616 fn shannon_entropy_uniform_is_maximal(
13617 len in 256..512usize,
13618 ) {
13619 #[allow(clippy::cast_possible_truncation)]
13621 let bytes: Vec<u8> = (0..len).map(|i| (i % 256) as u8).collect();
13622 let entropy = shannon_entropy_bytes(&bytes);
13623 assert!(
13625 entropy > 7.9,
13626 "uniform distribution entropy should be near 8.0, got {entropy}"
13627 );
13628 }
13629
13630 #[test]
13631 fn llc_miss_proxy_bounded(
13632 total_depth in 0..10_000usize,
13633 overflow_depth in 0..10_000usize,
13634 rejected_total in 0..100_000u64,
13635 ) {
13636 let proxy = llc_miss_proxy(total_depth, overflow_depth, rejected_total);
13637 assert!(
13638 (0.0..=1.0).contains(&proxy),
13639 "llc_miss_proxy must be in [0.0, 1.0], got {proxy}"
13640 );
13641 }
13642
13643 #[test]
13644 fn llc_miss_proxy_zero_on_empty(_dummy in Just(())) {
13645 let proxy = llc_miss_proxy(0, 0, 0);
13646 assert!(
13647 proxy.abs() < f64::EPSILON,
13648 "llc_miss_proxy(0, 0, 0) must be 0.0"
13649 );
13650 }
13651
13652 #[test]
13653 fn normalized_shadow_op_idempotent(op in "[a-zA-Z_]{1,20}") {
13654 let once = normalized_shadow_op(&op);
13655 let twice = normalized_shadow_op(&once);
13656 assert!(
13657 once == twice,
13658 "normalized_shadow_op must be idempotent: '{once}' vs '{twice}'"
13659 );
13660 }
13661
13662 #[test]
13663 fn normalized_shadow_op_case_insensitive(op in "[a-zA-Z]{1,20}") {
13664 let lower = normalized_shadow_op(&op.to_lowercase());
13665 let upper = normalized_shadow_op(&op.to_uppercase());
13666 assert!(
13667 lower == upper,
13668 "normalized_shadow_op must be case-insensitive: '{lower}' vs '{upper}'"
13669 );
13670 }
13671
13672 #[test]
13673 fn shadow_safe_session_op_case_insensitive(
13674 op in prop::sample::select(vec![
13675 "getState".to_string(),
13676 "GETSTATE".to_string(),
13677 "get_state".to_string(),
13678 "GET_STATE".to_string(),
13679 "getMessages".to_string(),
13680 "GET_MESSAGES".to_string(),
13681 ]),
13682 ) {
13683 assert!(
13684 shadow_safe_session_op(&op),
13685 "'{op}' should be recognized as safe session op"
13686 );
13687 }
13688
13689 #[test]
13690 fn shadow_safe_tool_case_insensitive(
13691 name in prop::sample::select(vec![
13692 "Read".to_string(),
13693 "READ".to_string(),
13694 "read".to_string(),
13695 "Grep".to_string(),
13696 "GREP".to_string(),
13697 ]),
13698 ) {
13699 assert!(
13700 shadow_safe_tool(&name),
13701 "'{name}' should be safe tool"
13702 );
13703 }
13704
13705 #[test]
13706 fn usize_to_f64_monotonic(a in 0..u32::MAX as usize, b in 0..u32::MAX as usize) {
13707 let fa = usize_to_f64(a);
13708 let fb = usize_to_f64(b);
13709 if a <= b {
13710 assert!(
13711 fa <= fb,
13712 "usize_to_f64 must be monotonic: {a} → {fa}, {b} → {fb}"
13713 );
13714 }
13715 }
13716 }
13717 }
13718}