1use std::cell::RefCell;
2use std::collections::BTreeMap;
3use std::process::Stdio;
4use std::rc::Rc;
5
6use serde_json::Value as JsonValue;
7
8use crate::value::{values_equal, VmError, VmValue};
9use crate::vm::clone_async_builtin_child_vm;
10use crate::vm::Vm;
11
12#[derive(Clone)]
13struct HostMock {
14 capability: String,
15 operation: String,
16 params: Option<BTreeMap<String, VmValue>>,
17 result: Option<VmValue>,
18 error: Option<String>,
19}
20
21#[derive(Clone)]
22struct HostMockCall {
23 capability: String,
24 operation: String,
25 params: BTreeMap<String, VmValue>,
26}
27
28thread_local! {
29 static HOST_MOCKS: RefCell<Vec<HostMock>> = const { RefCell::new(Vec::new()) };
30 static HOST_MOCK_CALLS: RefCell<Vec<HostMockCall>> = const { RefCell::new(Vec::new()) };
31}
32
33pub(crate) fn reset_host_state() {
34 HOST_MOCKS.with(|mocks| mocks.borrow_mut().clear());
35 HOST_MOCK_CALLS.with(|calls| calls.borrow_mut().clear());
36}
37
38fn capability_manifest_map() -> BTreeMap<String, VmValue> {
39 let mut root = BTreeMap::new();
40 root.insert(
41 "process".to_string(),
42 capability(
43 "Process execution.",
44 &[op("exec", "Execute a shell command.")],
45 ),
46 );
47 root.insert(
48 "template".to_string(),
49 capability(
50 "Template rendering.",
51 &[op("render", "Render a template file.")],
52 ),
53 );
54 root.insert(
55 "interaction".to_string(),
56 capability(
57 "User interaction.",
58 &[op("ask", "Ask the user a question.")],
59 ),
60 );
61 root
62}
63
64fn mocked_operation_entry() -> VmValue {
65 op(
66 "mocked",
67 "Mocked host operation registered at runtime for tests.",
68 )
69 .1
70}
71
72fn ensure_mocked_capability(
73 root: &mut BTreeMap<String, VmValue>,
74 capability_name: &str,
75 operation_name: &str,
76) {
77 let Some(existing) = root.get(capability_name).cloned() else {
78 root.insert(
79 capability_name.to_string(),
80 capability(
81 "Mocked host capability registered at runtime for tests.",
82 &[(operation_name.to_string(), mocked_operation_entry())],
83 ),
84 );
85 return;
86 };
87
88 let Some(existing_dict) = existing.as_dict() else {
89 return;
90 };
91 let mut entry = (*existing_dict).clone();
92 let mut ops = entry
93 .get("ops")
94 .and_then(|value| match value {
95 VmValue::List(list) => Some((**list).clone()),
96 _ => None,
97 })
98 .unwrap_or_default();
99 if !ops.iter().any(|value| value.display() == operation_name) {
100 ops.push(VmValue::String(Rc::from(operation_name.to_string())));
101 }
102
103 let mut operations = entry
104 .get("operations")
105 .and_then(|value| value.as_dict())
106 .map(|dict| (*dict).clone())
107 .unwrap_or_default();
108 operations
109 .entry(operation_name.to_string())
110 .or_insert_with(mocked_operation_entry);
111
112 entry.insert("ops".to_string(), VmValue::List(Rc::new(ops)));
113 entry.insert("operations".to_string(), VmValue::Dict(Rc::new(operations)));
114 root.insert(capability_name.to_string(), VmValue::Dict(Rc::new(entry)));
115}
116
117fn capability_manifest_with_mocks() -> VmValue {
118 let mut root = capability_manifest_map();
119 HOST_MOCKS.with(|mocks| {
120 for host_mock in mocks.borrow().iter() {
121 ensure_mocked_capability(&mut root, &host_mock.capability, &host_mock.operation);
122 }
123 });
124 VmValue::Dict(Rc::new(root))
125}
126
127fn op(name: &str, description: &str) -> (String, VmValue) {
128 let mut entry = BTreeMap::new();
129 entry.insert(
130 "description".to_string(),
131 VmValue::String(Rc::from(description)),
132 );
133 (name.to_string(), VmValue::Dict(Rc::new(entry)))
134}
135
136fn capability(description: &str, ops: &[(String, VmValue)]) -> VmValue {
137 let mut entry = BTreeMap::new();
138 entry.insert(
139 "description".to_string(),
140 VmValue::String(Rc::from(description)),
141 );
142 entry.insert(
143 "ops".to_string(),
144 VmValue::List(Rc::new(
145 ops.iter()
146 .map(|(name, _)| VmValue::String(Rc::from(name.as_str())))
147 .collect(),
148 )),
149 );
150 let mut op_dict = BTreeMap::new();
151 for (name, op) in ops {
152 op_dict.insert(name.clone(), op.clone());
153 }
154 entry.insert("operations".to_string(), VmValue::Dict(Rc::new(op_dict)));
155 VmValue::Dict(Rc::new(entry))
156}
157
158fn require_param(params: &BTreeMap<String, VmValue>, key: &str) -> Result<String, VmError> {
159 params
160 .get(key)
161 .map(|v| v.display())
162 .filter(|v| !v.is_empty())
163 .ok_or_else(|| {
164 VmError::Thrown(VmValue::String(Rc::from(format!(
165 "host_call: missing required parameter '{key}'"
166 ))))
167 })
168}
169
170fn render_template(
171 path: &str,
172 bindings: Option<&BTreeMap<String, VmValue>>,
173) -> Result<String, VmError> {
174 let resolved = crate::stdlib::process::resolve_source_asset_path(path);
175 let template = std::fs::read_to_string(&resolved).map_err(|e| {
176 VmError::Thrown(VmValue::String(Rc::from(format!(
177 "host_call template.render: failed to read template {}: {e}",
178 resolved.display()
179 ))))
180 })?;
181 let base = resolved.parent();
182 crate::stdlib::template::render_template_result(&template, bindings, base, Some(&resolved))
183 .map_err(VmError::from)
184}
185
186fn params_match(
187 expected: Option<&BTreeMap<String, VmValue>>,
188 actual: &BTreeMap<String, VmValue>,
189) -> bool {
190 let Some(expected) = expected else {
191 return true;
192 };
193 expected.iter().all(|(key, value)| {
194 actual
195 .get(key)
196 .is_some_and(|candidate| values_equal(candidate, value))
197 })
198}
199
200fn parse_host_mock(args: &[VmValue]) -> Result<HostMock, VmError> {
201 let capability = args
202 .first()
203 .map(|value| value.display())
204 .unwrap_or_default();
205 let operation = args.get(1).map(|value| value.display()).unwrap_or_default();
206 if capability.is_empty() || operation.is_empty() {
207 return Err(VmError::Thrown(VmValue::String(Rc::from(
208 "host_mock: capability and operation are required",
209 ))));
210 }
211
212 let mut params = args
213 .get(3)
214 .and_then(|value| value.as_dict())
215 .map(|dict| (*dict).clone());
216 let mut result = args.get(2).cloned().or(Some(VmValue::Nil));
217 let mut error = None;
218
219 if let Some(config) = args.get(2).and_then(|value| value.as_dict()) {
220 if config.contains_key("result")
221 || config.contains_key("params")
222 || config.contains_key("error")
223 {
224 params = config
225 .get("params")
226 .and_then(|value| value.as_dict())
227 .map(|dict| (*dict).clone());
228 result = config.get("result").cloned();
229 error = config
230 .get("error")
231 .map(|value| value.display())
232 .filter(|value| !value.is_empty());
233 }
234 }
235
236 Ok(HostMock {
237 capability,
238 operation,
239 params,
240 result,
241 error,
242 })
243}
244
245fn push_host_mock(host_mock: HostMock) {
246 HOST_MOCKS.with(|mocks| mocks.borrow_mut().push(host_mock));
247}
248
249fn mock_call_value(call: &HostMockCall) -> VmValue {
250 let mut item = BTreeMap::new();
251 item.insert(
252 "capability".to_string(),
253 VmValue::String(Rc::from(call.capability.clone())),
254 );
255 item.insert(
256 "operation".to_string(),
257 VmValue::String(Rc::from(call.operation.clone())),
258 );
259 item.insert(
260 "params".to_string(),
261 VmValue::Dict(Rc::new(call.params.clone())),
262 );
263 VmValue::Dict(Rc::new(item))
264}
265
266fn record_mock_call(capability: &str, operation: &str, params: &BTreeMap<String, VmValue>) {
267 HOST_MOCK_CALLS.with(|calls| {
268 calls.borrow_mut().push(HostMockCall {
269 capability: capability.to_string(),
270 operation: operation.to_string(),
271 params: params.clone(),
272 });
273 });
274}
275
276pub(crate) fn dispatch_mock_host_call(
277 capability: &str,
278 operation: &str,
279 params: &BTreeMap<String, VmValue>,
280) -> Option<Result<VmValue, VmError>> {
281 let matched = HOST_MOCKS.with(|mocks| {
282 mocks
283 .borrow()
284 .iter()
285 .rev()
286 .find(|host_mock| {
287 host_mock.capability == capability
288 && host_mock.operation == operation
289 && params_match(host_mock.params.as_ref(), params)
290 })
291 .cloned()
292 })?;
293
294 record_mock_call(capability, operation, params);
295 if let Some(error) = matched.error {
296 return Some(Err(VmError::Thrown(VmValue::String(Rc::from(error)))));
297 }
298 Some(Ok(matched.result.unwrap_or(VmValue::Nil)))
299}
300
301pub trait HostCallBridge {
316 fn dispatch(
317 &self,
318 capability: &str,
319 operation: &str,
320 params: &BTreeMap<String, VmValue>,
321 ) -> Result<Option<VmValue>, VmError>;
322
323 fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
324 Ok(None)
325 }
326
327 fn call_tool(&self, _name: &str, _args: &VmValue) -> Result<Option<VmValue>, VmError> {
328 Ok(None)
329 }
330}
331
332thread_local! {
333 static HOST_CALL_BRIDGE: RefCell<Option<Rc<dyn HostCallBridge>>> = const { RefCell::new(None) };
334}
335
336pub fn set_host_call_bridge(bridge: Rc<dyn HostCallBridge>) {
341 HOST_CALL_BRIDGE.with(|b| *b.borrow_mut() = Some(bridge));
342}
343
344pub fn clear_host_call_bridge() {
346 HOST_CALL_BRIDGE.with(|b| *b.borrow_mut() = None);
347}
348
349fn empty_tool_list_value() -> VmValue {
350 VmValue::List(Rc::new(Vec::new()))
351}
352
353fn current_vm_host_bridge() -> Option<Rc<crate::bridge::HostBridge>> {
354 clone_async_builtin_child_vm().and_then(|vm| vm.bridge.clone())
355}
356
357async fn dispatch_host_tool_list() -> Result<VmValue, VmError> {
358 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
359 if let Some(bridge) = bridge {
360 if let Some(value) = bridge.list_tools()? {
361 return Ok(value);
362 }
363 }
364
365 let Some(bridge) = current_vm_host_bridge() else {
366 return Ok(empty_tool_list_value());
367 };
368 let tools = bridge.list_host_tools().await?;
369 Ok(crate::bridge::json_result_to_vm_value(&JsonValue::Array(
370 tools.into_iter().collect(),
371 )))
372}
373
374async fn dispatch_host_tool_call(name: &str, args: &VmValue) -> Result<VmValue, VmError> {
375 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
376 if let Some(bridge) = bridge {
377 if let Some(value) = bridge.call_tool(name, args)? {
378 return Ok(value);
379 }
380 }
381
382 let Some(bridge) = current_vm_host_bridge() else {
383 return Err(VmError::Thrown(VmValue::String(Rc::from(
384 "host_tool_call: no host bridge is attached",
385 ))));
386 };
387
388 let result = bridge
389 .call(
390 "builtin_call",
391 serde_json::json!({
392 "name": name,
393 "args": [crate::llm::vm_value_to_json(args)],
394 }),
395 )
396 .await?;
397 Ok(crate::bridge::json_result_to_vm_value(&result))
398}
399
400async fn dispatch_host_operation(
401 capability: &str,
402 operation: &str,
403 params: &BTreeMap<String, VmValue>,
404) -> Result<VmValue, VmError> {
405 if let Some(mocked) = dispatch_mock_host_call(capability, operation, params) {
406 return mocked;
407 }
408
409 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
410 if let Some(bridge) = bridge {
411 if let Some(value) = bridge.dispatch(capability, operation, params)? {
412 return Ok(value);
413 }
414 }
415
416 match (capability, operation) {
417 ("process", "exec") => {
418 let command = require_param(params, "command")?;
419 let mut cmd = if cfg!(windows) {
420 let mut c = tokio::process::Command::new("cmd");
421 c.arg("/C").arg(&command);
422 c
423 } else {
424 let mut c = tokio::process::Command::new("/bin/sh");
425 c.arg("-lc").arg(&command);
426 c
427 };
428 let output = cmd
429 .stdin(Stdio::null())
430 .output()
431 .await
432 .map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
433 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
434 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
435 let mut result = BTreeMap::new();
436 result.insert(
437 "stdout".to_string(),
438 VmValue::String(Rc::from(stdout.clone())),
439 );
440 result.insert(
441 "stderr".to_string(),
442 VmValue::String(Rc::from(stderr.clone())),
443 );
444 result.insert(
445 "combined".to_string(),
446 VmValue::String(Rc::from(format!("{stdout}{stderr}"))),
447 );
448 let status = output.status.code().unwrap_or(-1);
449 result.insert("status".to_string(), VmValue::Int(status as i64));
450 result.insert(
451 "success".to_string(),
452 VmValue::Bool(output.status.success()),
453 );
454 Ok(VmValue::Dict(Rc::new(result)))
455 }
456 ("template", "render") => {
457 let path = require_param(params, "path")?;
458 let bindings = params.get("bindings").and_then(|v| v.as_dict());
459 Ok(VmValue::String(Rc::from(render_template(&path, bindings)?)))
460 }
461 ("interaction", "ask") => {
462 let question = require_param(params, "question")?;
463 use std::io::BufRead;
464 print!("{question}");
465 let _ = std::io::Write::flush(&mut std::io::stdout());
466 let mut input = String::new();
467 if std::io::stdin().lock().read_line(&mut input).is_ok() {
468 Ok(VmValue::String(Rc::from(input.trim_end())))
469 } else {
470 Ok(VmValue::Nil)
471 }
472 }
473 ("runtime", "task") => Ok(VmValue::String(Rc::from(
478 std::env::var("HARN_TASK").unwrap_or_default(),
479 ))),
480 ("runtime", "set_result") => {
481 Ok(VmValue::Nil)
484 }
485 ("workspace", "project_root") => {
486 let path = std::env::var("HARN_PROJECT_ROOT").unwrap_or_else(|_| {
490 std::env::current_dir()
491 .map(|p| p.display().to_string())
492 .unwrap_or_default()
493 });
494 Ok(VmValue::String(Rc::from(path)))
495 }
496 ("workspace", "cwd") => {
497 let path = std::env::current_dir()
498 .map(|p| p.display().to_string())
499 .unwrap_or_default();
500 Ok(VmValue::String(Rc::from(path)))
501 }
502 _ => Err(VmError::Thrown(VmValue::String(Rc::from(format!(
503 "host_call: unsupported operation {capability}.{operation}"
504 ))))),
505 }
506}
507
508pub(crate) fn register_host_builtins(vm: &mut Vm) {
509 vm.register_builtin("host_mock", |args, _out| {
510 let host_mock = parse_host_mock(args)?;
511 push_host_mock(host_mock);
512 Ok(VmValue::Nil)
513 });
514
515 vm.register_builtin("host_mock_clear", |_args, _out| {
516 reset_host_state();
517 Ok(VmValue::Nil)
518 });
519
520 vm.register_builtin("host_mock_calls", |_args, _out| {
521 let calls = HOST_MOCK_CALLS.with(|calls| {
522 calls
523 .borrow()
524 .iter()
525 .map(mock_call_value)
526 .collect::<Vec<_>>()
527 });
528 Ok(VmValue::List(Rc::new(calls)))
529 });
530
531 vm.register_builtin("host_capabilities", |_args, _out| {
532 Ok(capability_manifest_with_mocks())
533 });
534
535 vm.register_builtin("host_has", |args, _out| {
536 let capability = args.first().map(|a| a.display()).unwrap_or_default();
537 let operation = args.get(1).map(|a| a.display());
538 let manifest = capability_manifest_with_mocks();
539 let has = manifest
540 .as_dict()
541 .and_then(|d| d.get(&capability))
542 .and_then(|v| v.as_dict())
543 .is_some_and(|cap| {
544 if let Some(operation) = operation {
545 cap.get("ops")
546 .and_then(|v| match v {
547 VmValue::List(list) => {
548 Some(list.iter().any(|item| item.display() == operation))
549 }
550 _ => None,
551 })
552 .unwrap_or(false)
553 } else {
554 true
555 }
556 });
557 Ok(VmValue::Bool(has))
558 });
559
560 vm.register_async_builtin("host_call", |args| async move {
561 let name = args.first().map(|a| a.display()).unwrap_or_default();
562 let params = args
563 .get(1)
564 .and_then(|a| a.as_dict())
565 .cloned()
566 .unwrap_or_default();
567 let Some((capability, operation)) = name.split_once('.') else {
568 return Err(VmError::Thrown(VmValue::String(Rc::from(format!(
569 "host_call: unsupported operation name '{name}'"
570 )))));
571 };
572 dispatch_host_operation(capability, operation, ¶ms).await
573 });
574
575 vm.register_async_builtin("host_tool_list", |_args| async move {
576 dispatch_host_tool_list().await
577 });
578
579 vm.register_async_builtin("host_tool_call", |args| async move {
580 let name = args.first().map(|a| a.display()).unwrap_or_default();
581 if name.is_empty() {
582 return Err(VmError::Thrown(VmValue::String(Rc::from(
583 "host_tool_call: tool name is required",
584 ))));
585 }
586 let call_args = args.get(1).cloned().unwrap_or(VmValue::Nil);
587 dispatch_host_tool_call(&name, &call_args).await
588 });
589}
590
591#[cfg(test)]
592mod tests {
593 use super::{
594 capability_manifest_with_mocks, clear_host_call_bridge, dispatch_host_tool_call,
595 dispatch_host_tool_list, dispatch_mock_host_call, push_host_mock, reset_host_state,
596 set_host_call_bridge, HostCallBridge, HostMock,
597 };
598 use std::collections::BTreeMap;
599 use std::rc::Rc;
600
601 use crate::value::{VmError, VmValue};
602
603 #[test]
604 fn manifest_includes_operation_metadata() {
605 let manifest = capability_manifest_with_mocks();
606 let process = manifest
607 .as_dict()
608 .and_then(|d| d.get("process"))
609 .and_then(|v| v.as_dict())
610 .expect("process capability");
611 assert!(process.get("description").is_some());
612 let operations = process
613 .get("operations")
614 .and_then(|v| v.as_dict())
615 .expect("operations dict");
616 assert!(operations.get("exec").is_some());
617 }
618
619 #[test]
620 fn mocked_capabilities_appear_in_manifest() {
621 reset_host_state();
622 push_host_mock(HostMock {
623 capability: "project".to_string(),
624 operation: "metadata_get".to_string(),
625 params: None,
626 result: Some(VmValue::Dict(Rc::new(BTreeMap::new()))),
627 error: None,
628 });
629 let manifest = capability_manifest_with_mocks();
630 let project = manifest
631 .as_dict()
632 .and_then(|d| d.get("project"))
633 .and_then(|v| v.as_dict())
634 .expect("project capability");
635 let operations = project
636 .get("operations")
637 .and_then(|v| v.as_dict())
638 .expect("operations dict");
639 assert!(operations.get("metadata_get").is_some());
640 reset_host_state();
641 }
642
643 #[test]
644 fn mock_host_call_matches_partial_params_and_overrides_order() {
645 reset_host_state();
646 let mut exact_params = BTreeMap::new();
647 exact_params.insert("namespace".to_string(), VmValue::String(Rc::from("facts")));
648 push_host_mock(HostMock {
649 capability: "project".to_string(),
650 operation: "metadata_get".to_string(),
651 params: None,
652 result: Some(VmValue::String(Rc::from("fallback"))),
653 error: None,
654 });
655 push_host_mock(HostMock {
656 capability: "project".to_string(),
657 operation: "metadata_get".to_string(),
658 params: Some(exact_params),
659 result: Some(VmValue::String(Rc::from("facts"))),
660 error: None,
661 });
662
663 let mut call_params = BTreeMap::new();
664 call_params.insert("dir".to_string(), VmValue::String(Rc::from("pkg")));
665 call_params.insert("namespace".to_string(), VmValue::String(Rc::from("facts")));
666 let exact = dispatch_mock_host_call("project", "metadata_get", &call_params)
667 .expect("expected exact mock")
668 .expect("exact mock should succeed");
669 assert_eq!(exact.display(), "facts");
670
671 call_params.insert(
672 "namespace".to_string(),
673 VmValue::String(Rc::from("classification")),
674 );
675 let fallback = dispatch_mock_host_call("project", "metadata_get", &call_params)
676 .expect("expected fallback mock")
677 .expect("fallback mock should succeed");
678 assert_eq!(fallback.display(), "fallback");
679 reset_host_state();
680 }
681
682 #[test]
683 fn mock_host_call_can_throw_errors() {
684 reset_host_state();
685 push_host_mock(HostMock {
686 capability: "project".to_string(),
687 operation: "metadata_get".to_string(),
688 params: None,
689 result: None,
690 error: Some("boom".to_string()),
691 });
692 let params = BTreeMap::new();
693 let result = dispatch_mock_host_call("project", "metadata_get", ¶ms)
694 .expect("expected mock result");
695 match result {
696 Err(VmError::Thrown(VmValue::String(message))) => assert_eq!(message.as_ref(), "boom"),
697 other => panic!("unexpected result: {other:?}"),
698 }
699 reset_host_state();
700 }
701
702 #[derive(Default)]
703 struct TestHostToolBridge;
704
705 impl HostCallBridge for TestHostToolBridge {
706 fn dispatch(
707 &self,
708 _capability: &str,
709 _operation: &str,
710 _params: &BTreeMap<String, VmValue>,
711 ) -> Result<Option<VmValue>, VmError> {
712 Ok(None)
713 }
714
715 fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
716 let tool = VmValue::Dict(Rc::new(BTreeMap::from([
717 (
718 "name".to_string(),
719 VmValue::String(Rc::from("Read".to_string())),
720 ),
721 (
722 "description".to_string(),
723 VmValue::String(Rc::from("Read a file from the host".to_string())),
724 ),
725 (
726 "schema".to_string(),
727 VmValue::Dict(Rc::new(BTreeMap::from([(
728 "type".to_string(),
729 VmValue::String(Rc::from("object".to_string())),
730 )]))),
731 ),
732 ("deprecated".to_string(), VmValue::Bool(false)),
733 ])));
734 Ok(Some(VmValue::List(Rc::new(vec![tool]))))
735 }
736
737 fn call_tool(&self, name: &str, args: &VmValue) -> Result<Option<VmValue>, VmError> {
738 if name != "Read" {
739 return Ok(None);
740 }
741 let path = args
742 .as_dict()
743 .and_then(|dict| dict.get("path"))
744 .map(|value| value.display())
745 .unwrap_or_default();
746 Ok(Some(VmValue::String(Rc::from(format!("read:{path}")))))
747 }
748 }
749
750 fn run_host_async_test<F, Fut>(test: F)
751 where
752 F: FnOnce() -> Fut,
753 Fut: std::future::Future<Output = ()>,
754 {
755 let rt = tokio::runtime::Builder::new_current_thread()
756 .enable_all()
757 .build()
758 .expect("runtime");
759 rt.block_on(async {
760 let local = tokio::task::LocalSet::new();
761 local.run_until(test()).await;
762 });
763 }
764
765 #[test]
766 fn host_tool_list_uses_installed_host_call_bridge() {
767 run_host_async_test(|| async {
768 reset_host_state();
769 set_host_call_bridge(Rc::new(TestHostToolBridge));
770 let tools = dispatch_host_tool_list().await.expect("tool list");
771 clear_host_call_bridge();
772
773 let VmValue::List(items) = tools else {
774 panic!("expected tool list");
775 };
776 assert_eq!(items.len(), 1);
777 let tool = items[0].as_dict().expect("tool dict");
778 assert_eq!(tool.get("name").unwrap().display(), "Read");
779 assert_eq!(tool.get("deprecated").unwrap().display(), "false");
780 });
781 }
782
783 #[test]
784 fn host_tool_call_uses_installed_host_call_bridge() {
785 run_host_async_test(|| async {
786 set_host_call_bridge(Rc::new(TestHostToolBridge));
787 let args = VmValue::Dict(Rc::new(BTreeMap::from([(
788 "path".to_string(),
789 VmValue::String(Rc::from("README.md".to_string())),
790 )])));
791 let value = dispatch_host_tool_call("Read", &args)
792 .await
793 .expect("tool call");
794 clear_host_call_bridge();
795 assert_eq!(value.display(), "read:README.md");
796 });
797 }
798
799 #[test]
800 fn host_tool_list_is_empty_without_bridge() {
801 run_host_async_test(|| async {
802 clear_host_call_bridge();
803 let tools = dispatch_host_tool_list().await.expect("tool list");
804 let VmValue::List(items) = tools else {
805 panic!("expected tool list");
806 };
807 assert!(items.is_empty());
808 });
809 }
810}