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 output = tokio::process::Command::new("/bin/sh")
420                .arg("-lc")
421                .arg(&command)
422                .stdin(Stdio::null())
423                .output()
424                .await
425                .map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
426            let stdout = String::from_utf8_lossy(&output.stdout).to_string();
427            let stderr = String::from_utf8_lossy(&output.stderr).to_string();
428            let mut result = BTreeMap::new();
429            result.insert(
430                "stdout".to_string(),
431                VmValue::String(Rc::from(stdout.clone())),
432            );
433            result.insert(
434                "stderr".to_string(),
435                VmValue::String(Rc::from(stderr.clone())),
436            );
437            result.insert(
438                "combined".to_string(),
439                VmValue::String(Rc::from(format!("{stdout}{stderr}"))),
440            );
441            let status = output.status.code().unwrap_or(-1);
442            result.insert("status".to_string(), VmValue::Int(status as i64));
443            result.insert(
444                "success".to_string(),
445                VmValue::Bool(output.status.success()),
446            );
447            Ok(VmValue::Dict(Rc::new(result)))
448        }
449        ("template", "render") => {
450            let path = require_param(params, "path")?;
451            let bindings = params.get("bindings").and_then(|v| v.as_dict());
452            Ok(VmValue::String(Rc::from(render_template(&path, bindings)?)))
453        }
454        ("interaction", "ask") => {
455            let question = require_param(params, "question")?;
456            use std::io::BufRead;
457            print!("{question}");
458            let _ = std::io::Write::flush(&mut std::io::stdout());
459            let mut input = String::new();
460            if std::io::stdin().lock().read_line(&mut input).is_ok() {
461                Ok(VmValue::String(Rc::from(input.trim_end())))
462            } else {
463                Ok(VmValue::Nil)
464            }
465        }
466        // Standalone-run fallbacks for capabilities normally supplied by
467        // an embedder's JSON-RPC bridge. `runtime.task` lets a debugger or
468        // CLI invocation read the pipeline input from `HARN_TASK` without
469        // the host explicitly wiring a callback for every op.
470        ("runtime", "task") => Ok(VmValue::String(Rc::from(
471            std::env::var("HARN_TASK").unwrap_or_default(),
472        ))),
473        ("runtime", "set_result") => {
474            // No-op when no host is attached; swallow silently so standalone
475            // scripts can still call `set_result` without crashing.
476            Ok(VmValue::Nil)
477        }
478        ("workspace", "project_root") => {
479            // Standalone fallback: prefer HARN_PROJECT_ROOT, then the
480            // current working directory. Pipelines call this very early so
481            // crashing here would block any debug-launched script.
482            let path = std::env::var("HARN_PROJECT_ROOT").unwrap_or_else(|_| {
483                std::env::current_dir()
484                    .map(|p| p.display().to_string())
485                    .unwrap_or_default()
486            });
487            Ok(VmValue::String(Rc::from(path)))
488        }
489        ("workspace", "cwd") => {
490            let path = std::env::current_dir()
491                .map(|p| p.display().to_string())
492                .unwrap_or_default();
493            Ok(VmValue::String(Rc::from(path)))
494        }
495        _ => Err(VmError::Thrown(VmValue::String(Rc::from(format!(
496            "host_call: unsupported operation {capability}.{operation}"
497        ))))),
498    }
499}
500
501pub(crate) fn register_host_builtins(vm: &mut Vm) {
502    vm.register_builtin("host_mock", |args, _out| {
503        let host_mock = parse_host_mock(args)?;
504        push_host_mock(host_mock);
505        Ok(VmValue::Nil)
506    });
507
508    vm.register_builtin("host_mock_clear", |_args, _out| {
509        reset_host_state();
510        Ok(VmValue::Nil)
511    });
512
513    vm.register_builtin("host_mock_calls", |_args, _out| {
514        let calls = HOST_MOCK_CALLS.with(|calls| {
515            calls
516                .borrow()
517                .iter()
518                .map(mock_call_value)
519                .collect::<Vec<_>>()
520        });
521        Ok(VmValue::List(Rc::new(calls)))
522    });
523
524    vm.register_builtin("host_capabilities", |_args, _out| {
525        Ok(capability_manifest_with_mocks())
526    });
527
528    vm.register_builtin("host_has", |args, _out| {
529        let capability = args.first().map(|a| a.display()).unwrap_or_default();
530        let operation = args.get(1).map(|a| a.display());
531        let manifest = capability_manifest_with_mocks();
532        let has = manifest
533            .as_dict()
534            .and_then(|d| d.get(&capability))
535            .and_then(|v| v.as_dict())
536            .is_some_and(|cap| {
537                if let Some(operation) = operation {
538                    cap.get("ops")
539                        .and_then(|v| match v {
540                            VmValue::List(list) => {
541                                Some(list.iter().any(|item| item.display() == operation))
542                            }
543                            _ => None,
544                        })
545                        .unwrap_or(false)
546                } else {
547                    true
548                }
549            });
550        Ok(VmValue::Bool(has))
551    });
552
553    vm.register_async_builtin("host_call", |args| async move {
554        let name = args.first().map(|a| a.display()).unwrap_or_default();
555        let params = args
556            .get(1)
557            .and_then(|a| a.as_dict())
558            .cloned()
559            .unwrap_or_default();
560        let Some((capability, operation)) = name.split_once('.') else {
561            return Err(VmError::Thrown(VmValue::String(Rc::from(format!(
562                "host_call: unsupported operation name '{name}'"
563            )))));
564        };
565        dispatch_host_operation(capability, operation, &params).await
566    });
567
568    vm.register_async_builtin("host_tool_list", |_args| async move {
569        dispatch_host_tool_list().await
570    });
571
572    vm.register_async_builtin("host_tool_call", |args| async move {
573        let name = args.first().map(|a| a.display()).unwrap_or_default();
574        if name.is_empty() {
575            return Err(VmError::Thrown(VmValue::String(Rc::from(
576                "host_tool_call: tool name is required",
577            ))));
578        }
579        let call_args = args.get(1).cloned().unwrap_or(VmValue::Nil);
580        dispatch_host_tool_call(&name, &call_args).await
581    });
582}
583
584#[cfg(test)]
585mod tests {
586    use super::{
587        capability_manifest_with_mocks, clear_host_call_bridge, dispatch_host_tool_call,
588        dispatch_host_tool_list, dispatch_mock_host_call, push_host_mock, reset_host_state,
589        set_host_call_bridge, HostCallBridge, HostMock,
590    };
591    use std::collections::BTreeMap;
592    use std::rc::Rc;
593
594    use crate::value::{VmError, VmValue};
595
596    #[test]
597    fn manifest_includes_operation_metadata() {
598        let manifest = capability_manifest_with_mocks();
599        let process = manifest
600            .as_dict()
601            .and_then(|d| d.get("process"))
602            .and_then(|v| v.as_dict())
603            .expect("process capability");
604        assert!(process.get("description").is_some());
605        let operations = process
606            .get("operations")
607            .and_then(|v| v.as_dict())
608            .expect("operations dict");
609        assert!(operations.get("exec").is_some());
610    }
611
612    #[test]
613    fn mocked_capabilities_appear_in_manifest() {
614        reset_host_state();
615        push_host_mock(HostMock {
616            capability: "project".to_string(),
617            operation: "metadata_get".to_string(),
618            params: None,
619            result: Some(VmValue::Dict(Rc::new(BTreeMap::new()))),
620            error: None,
621        });
622        let manifest = capability_manifest_with_mocks();
623        let project = manifest
624            .as_dict()
625            .and_then(|d| d.get("project"))
626            .and_then(|v| v.as_dict())
627            .expect("project capability");
628        let operations = project
629            .get("operations")
630            .and_then(|v| v.as_dict())
631            .expect("operations dict");
632        assert!(operations.get("metadata_get").is_some());
633        reset_host_state();
634    }
635
636    #[test]
637    fn mock_host_call_matches_partial_params_and_overrides_order() {
638        reset_host_state();
639        let mut exact_params = BTreeMap::new();
640        exact_params.insert("namespace".to_string(), VmValue::String(Rc::from("facts")));
641        push_host_mock(HostMock {
642            capability: "project".to_string(),
643            operation: "metadata_get".to_string(),
644            params: None,
645            result: Some(VmValue::String(Rc::from("fallback"))),
646            error: None,
647        });
648        push_host_mock(HostMock {
649            capability: "project".to_string(),
650            operation: "metadata_get".to_string(),
651            params: Some(exact_params),
652            result: Some(VmValue::String(Rc::from("facts"))),
653            error: None,
654        });
655
656        let mut call_params = BTreeMap::new();
657        call_params.insert("dir".to_string(), VmValue::String(Rc::from("pkg")));
658        call_params.insert("namespace".to_string(), VmValue::String(Rc::from("facts")));
659        let exact = dispatch_mock_host_call("project", "metadata_get", &call_params)
660            .expect("expected exact mock")
661            .expect("exact mock should succeed");
662        assert_eq!(exact.display(), "facts");
663
664        call_params.insert(
665            "namespace".to_string(),
666            VmValue::String(Rc::from("classification")),
667        );
668        let fallback = dispatch_mock_host_call("project", "metadata_get", &call_params)
669            .expect("expected fallback mock")
670            .expect("fallback mock should succeed");
671        assert_eq!(fallback.display(), "fallback");
672        reset_host_state();
673    }
674
675    #[test]
676    fn mock_host_call_can_throw_errors() {
677        reset_host_state();
678        push_host_mock(HostMock {
679            capability: "project".to_string(),
680            operation: "metadata_get".to_string(),
681            params: None,
682            result: None,
683            error: Some("boom".to_string()),
684        });
685        let params = BTreeMap::new();
686        let result = dispatch_mock_host_call("project", "metadata_get", &params)
687            .expect("expected mock result");
688        match result {
689            Err(VmError::Thrown(VmValue::String(message))) => assert_eq!(message.as_ref(), "boom"),
690            other => panic!("unexpected result: {other:?}"),
691        }
692        reset_host_state();
693    }
694
695    #[derive(Default)]
696    struct TestHostToolBridge;
697
698    impl HostCallBridge for TestHostToolBridge {
699        fn dispatch(
700            &self,
701            _capability: &str,
702            _operation: &str,
703            _params: &BTreeMap<String, VmValue>,
704        ) -> Result<Option<VmValue>, VmError> {
705            Ok(None)
706        }
707
708        fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
709            let tool = VmValue::Dict(Rc::new(BTreeMap::from([
710                (
711                    "name".to_string(),
712                    VmValue::String(Rc::from("Read".to_string())),
713                ),
714                (
715                    "description".to_string(),
716                    VmValue::String(Rc::from("Read a file from the host".to_string())),
717                ),
718                (
719                    "schema".to_string(),
720                    VmValue::Dict(Rc::new(BTreeMap::from([(
721                        "type".to_string(),
722                        VmValue::String(Rc::from("object".to_string())),
723                    )]))),
724                ),
725                ("deprecated".to_string(), VmValue::Bool(false)),
726            ])));
727            Ok(Some(VmValue::List(Rc::new(vec![tool]))))
728        }
729
730        fn call_tool(&self, name: &str, args: &VmValue) -> Result<Option<VmValue>, VmError> {
731            if name != "Read" {
732                return Ok(None);
733            }
734            let path = args
735                .as_dict()
736                .and_then(|dict| dict.get("path"))
737                .map(|value| value.display())
738                .unwrap_or_default();
739            Ok(Some(VmValue::String(Rc::from(format!("read:{path}")))))
740        }
741    }
742
743    fn run_host_async_test<F, Fut>(test: F)
744    where
745        F: FnOnce() -> Fut,
746        Fut: std::future::Future<Output = ()>,
747    {
748        let rt = tokio::runtime::Builder::new_current_thread()
749            .enable_all()
750            .build()
751            .expect("runtime");
752        rt.block_on(async {
753            let local = tokio::task::LocalSet::new();
754            local.run_until(test()).await;
755        });
756    }
757
758    #[test]
759    fn host_tool_list_uses_installed_host_call_bridge() {
760        run_host_async_test(|| async {
761            reset_host_state();
762            set_host_call_bridge(Rc::new(TestHostToolBridge));
763            let tools = dispatch_host_tool_list().await.expect("tool list");
764            clear_host_call_bridge();
765
766            let VmValue::List(items) = tools else {
767                panic!("expected tool list");
768            };
769            assert_eq!(items.len(), 1);
770            let tool = items[0].as_dict().expect("tool dict");
771            assert_eq!(tool.get("name").unwrap().display(), "Read");
772            assert_eq!(tool.get("deprecated").unwrap().display(), "false");
773        });
774    }
775
776    #[test]
777    fn host_tool_call_uses_installed_host_call_bridge() {
778        run_host_async_test(|| async {
779            set_host_call_bridge(Rc::new(TestHostToolBridge));
780            let args = VmValue::Dict(Rc::new(BTreeMap::from([(
781                "path".to_string(),
782                VmValue::String(Rc::from("README.md".to_string())),
783            )])));
784            let value = dispatch_host_tool_call("Read", &args)
785                .await
786                .expect("tool call");
787            clear_host_call_bridge();
788            assert_eq!(value.display(), "read:README.md");
789        });
790    }
791
792    #[test]
793    fn host_tool_list_is_empty_without_bridge() {
794        run_host_async_test(|| async {
795            clear_host_call_bridge();
796            let tools = dispatch_host_tool_list().await.expect("tool list");
797            let VmValue::List(items) = tools else {
798                panic!("expected tool list");
799            };
800            assert!(items.is_empty());
801        });
802    }
803}