Skip to main content

harn_vm/stdlib/
host.rs

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
301/// Embedder-supplied bridge for `host_call` ops.
302///
303/// Embedders (debug adapters, CLIs, IDE hosts) implement this trait to
304/// satisfy capability/operation pairs that harn-vm itself doesn't know how
305/// to handle. Returning `Ok(None)` means "I don't handle this op — fall
306/// through to the built-in fallbacks (env-derived defaults, then the
307/// `unsupported operation` error)". `Ok(Some(value))` is the result;
308/// `Err(VmError::Thrown(_))` surfaces as a Harn exception.
309///
310/// The trait is intentionally synchronous. Bridges that need async I/O
311/// (e.g. DAP reverse requests) should drive their own runtime or use a
312/// blocking channel — see `harn-dap`'s `DapHostBridge` for the canonical
313/// pattern. Sync keeps the boundary simple and avoids forcing the entire
314/// dispatch path into an opaque future.
315pub 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
336/// Install a bridge for the current thread. The bridge is consulted on
337/// every `host_call` *after* mock matching but *before* the built-in
338/// match arms, so embedders can override anything they like (and equally
339/// punt on anything they don't, by returning `Ok(None)`).
340pub fn set_host_call_bridge(bridge: Rc<dyn HostCallBridge>) {
341    HOST_CALL_BRIDGE.with(|b| *b.borrow_mut() = Some(bridge));
342}
343
344/// Remove the current thread's bridge. Idempotent.
345pub 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        // Standalone-run fallbacks for capabilities normally supplied by
474        // an embedder's JSON-RPC bridge. `runtime.task` lets a debugger or
475        // CLI invocation read the pipeline input from `HARN_TASK` without
476        // the host explicitly wiring a callback for every op.
477        ("runtime", "task") => Ok(VmValue::String(Rc::from(
478            std::env::var("HARN_TASK").unwrap_or_default(),
479        ))),
480        ("runtime", "set_result") => {
481            // No-op when no host is attached; swallow silently so standalone
482            // scripts can still call `set_result` without crashing.
483            Ok(VmValue::Nil)
484        }
485        ("workspace", "project_root") => {
486            // Standalone fallback: prefer HARN_PROJECT_ROOT, then the
487            // current working directory. Pipelines call this very early so
488            // crashing here would block any debug-launched script.
489            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, &params).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", &params)
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}