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