Skip to main content

lash_lashlang_runtime/
lib.rs

1use std::sync::{Arc, Mutex};
2
3pub use lash_trace::{
4    TraceLashlangChildExecution, TraceLashlangEdgeSelection, TraceLashlangExecutionEvent,
5    TraceLashlangExecutionIdentity, TraceLashlangGraph, TraceLashlangGraphChildLink,
6    TraceLashlangGraphEdge, TraceLashlangGraphNode, TraceLashlangGraphStore, TraceLashlangMap,
7    TraceLashlangMapEdge, TraceLashlangMapNode, TraceLashlangNodeStatus, TraceLashlangStatus,
8};
9pub use lashlang::{
10    CompiledProcessCache, DurabilityTier as LashlangDurabilityTier, InMemoryLashlangArtifactStore,
11    LASH_TYPE_KEY, LashlangAbilities, LashlangArtifactStore, LashlangHostCatalog,
12    LashlangHostEnvironment, LashlangLanguageFeatures,
13};
14
15pub const LASHLANG_ENGINE_KIND: &str = "lashlang";
16pub const LASHLANG_TOOL_BINDING_KEY: &str = "lashlang.tool";
17pub const LASHLANG_SURFACE_EXTENSION_ID: &str = "lashlang.surface";
18
19#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
20#[serde(default)]
21pub struct LashlangSurfaceContribution {
22    pub abilities: LashlangAbilities,
23    pub language_features: LashlangLanguageFeatures,
24    pub resources: LashlangHostCatalog,
25}
26
27impl LashlangSurfaceContribution {
28    pub fn new(
29        abilities: LashlangAbilities,
30        language_features: LashlangLanguageFeatures,
31        resources: LashlangHostCatalog,
32    ) -> Self {
33        Self {
34            abilities,
35            language_features,
36            resources,
37        }
38    }
39
40    pub fn from_surface(surface: LashlangSurface) -> Self {
41        Self {
42            abilities: surface.abilities,
43            language_features: surface.language_features,
44            resources: surface.resources,
45        }
46    }
47}
48
49#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
50pub struct LashlangToolBinding {
51    #[serde(default, skip_serializing_if = "Vec::is_empty")]
52    pub module_path: Vec<String>,
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub operation: Option<String>,
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub authority_type: Option<String>,
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    pub aliases: Vec<String>,
59}
60
61impl LashlangToolBinding {
62    pub fn new(
63        module_path: impl IntoIterator<Item = impl Into<String>>,
64        operation: impl Into<String>,
65    ) -> Self {
66        Self {
67            module_path: module_path.into_iter().map(Into::into).collect(),
68            operation: Some(operation.into()),
69            authority_type: None,
70            aliases: Vec::new(),
71        }
72    }
73
74    pub fn with_authority_type(mut self, authority_type: impl Into<String>) -> Self {
75        self.authority_type = Some(authority_type.into());
76        self
77    }
78
79    pub fn with_aliases(mut self, aliases: impl IntoIterator<Item = impl Into<String>>) -> Self {
80        self.aliases = aliases.into_iter().map(Into::into).collect();
81        self
82    }
83
84    pub fn executable_for(&self, tool_name: &str) -> ResolvedLashlangToolBinding {
85        let module_path = if self.module_path.is_empty() {
86            vec!["tools".to_string()]
87        } else {
88            self.module_path.clone()
89        };
90        let operation = self
91            .operation
92            .as_deref()
93            .filter(|operation| !operation.trim().is_empty())
94            .map(ToOwned::to_owned)
95            .unwrap_or_else(|| tool_name.replace('_', "."));
96        let authority_type = self
97            .authority_type
98            .as_deref()
99            .filter(|authority_type| !authority_type.trim().is_empty())
100            .map(ToOwned::to_owned)
101            .unwrap_or_else(|| default_authority_type(&module_path));
102        ResolvedLashlangToolBinding {
103            module_path,
104            operation,
105            authority_type,
106            aliases: self.aliases.clone(),
107        }
108    }
109
110    pub fn required_for_remote(
111        manifest: &lash_core::ToolManifest,
112    ) -> Result<ResolvedLashlangToolBinding, String> {
113        tool_lashlang_binding(manifest).required_executable_for_remote(&manifest.name)
114    }
115
116    pub fn required_executable_for_remote(
117        &self,
118        tool_name: &str,
119    ) -> Result<ResolvedLashlangToolBinding, String> {
120        if self.module_path.is_empty() {
121            return Err(format!(
122                "tool `{tool_name}` is missing an explicit remote module path"
123            ));
124        }
125        let operation = self
126            .operation
127            .as_deref()
128            .filter(|operation| !operation.trim().is_empty())
129            .ok_or_else(|| {
130                format!("tool `{tool_name}` is missing an explicit remote operation name")
131            })?;
132        let authority_type = self
133            .authority_type
134            .as_deref()
135            .filter(|authority_type| !authority_type.trim().is_empty())
136            .map(ToOwned::to_owned)
137            .unwrap_or_else(|| default_authority_type(&self.module_path));
138        Ok(ResolvedLashlangToolBinding {
139            module_path: self.module_path.clone(),
140            operation: operation.to_string(),
141            authority_type,
142            aliases: self.aliases.clone(),
143        })
144    }
145}
146
147#[derive(Clone, Debug, PartialEq, Eq)]
148pub struct ResolvedLashlangToolBinding {
149    pub module_path: Vec<String>,
150    pub operation: String,
151    pub authority_type: String,
152    pub aliases: Vec<String>,
153}
154
155impl ResolvedLashlangToolBinding {
156    pub fn module_path_string(&self) -> String {
157        self.module_path.join(".")
158    }
159
160    pub fn call_path(&self) -> String {
161        format!("{}.{}", self.module_path_string(), self.operation)
162    }
163}
164
165fn default_authority_type(module_path: &[String]) -> String {
166    module_path
167        .last()
168        .map(|segment| {
169            let mut chars = segment.chars();
170            match chars.next() {
171                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
172                None => "Tool".to_string(),
173            }
174        })
175        .unwrap_or_else(|| "Tool".to_string())
176}
177
178pub fn tool_lashlang_binding(manifest: &lash_core::ToolManifest) -> LashlangToolBinding {
179    manifest
180        .bindings
181        .get(LASHLANG_TOOL_BINDING_KEY)
182        .cloned()
183        .and_then(|value| serde_json::from_value(value).ok())
184        .unwrap_or_default()
185}
186
187pub trait ToolDefinitionLashlangExt {
188    fn with_lashlang_binding(self, lashlang_binding: LashlangToolBinding) -> Self;
189}
190
191impl ToolDefinitionLashlangExt for lash_core::ToolDefinition {
192    fn with_lashlang_binding(mut self, lashlang_binding: LashlangToolBinding) -> Self {
193        let value = serde_json::to_value(lashlang_binding)
194            .expect("lashlang tool binding must serialize to JSON");
195        self.manifest
196            .bindings
197            .insert(LASHLANG_TOOL_BINDING_KEY.to_string(), value);
198        self
199    }
200}
201
202#[derive(Clone, Debug)]
203pub struct LashlangSurface {
204    pub abilities: LashlangAbilities,
205    pub language_features: LashlangLanguageFeatures,
206    pub resources: LashlangHostCatalog,
207}
208
209impl Default for LashlangSurface {
210    fn default() -> Self {
211        Self {
212            abilities: LashlangAbilities::default().with_sleep(),
213            language_features: LashlangLanguageFeatures::default(),
214            resources: LashlangHostCatalog::new(),
215        }
216    }
217}
218
219impl LashlangSurface {
220    pub fn new(
221        abilities: LashlangAbilities,
222        language_features: LashlangLanguageFeatures,
223        resources: LashlangHostCatalog,
224    ) -> Self {
225        Self {
226            abilities,
227            language_features,
228            resources,
229        }
230    }
231
232    pub fn for_process_registry(mut self, process_registry_available: bool) -> Self {
233        self.abilities = self.abilities.with_sleep();
234        if process_registry_available {
235            self.abilities = self.abilities.with_processes().with_process_signals();
236        } else {
237            self.abilities.processes = false;
238            self.abilities.process_signals = false;
239        }
240        self
241    }
242
243    pub fn with_resources(mut self, resources: LashlangHostCatalog) -> Self {
244        self.resources.extend(resources);
245        self
246    }
247
248    pub fn with_plugin_extensions(
249        mut self,
250        extensions: &lash_core::PluginExtensions,
251    ) -> Result<Self, String> {
252        for payload in extensions.payloads(LASHLANG_SURFACE_EXTENSION_ID) {
253            let contribution: LashlangSurfaceContribution = serde_json::from_value(payload.clone())
254                .map_err(|err| {
255                    format!("invalid `{LASHLANG_SURFACE_EXTENSION_ID}` extension payload: {err}")
256                })?;
257            self.abilities = self.abilities.union(contribution.abilities);
258            self.language_features = self.language_features.union(contribution.language_features);
259            self.resources.extend(contribution.resources);
260        }
261        Ok(self)
262    }
263
264    pub fn host_environment(&self, catalog: &lash_core::ToolCatalog) -> LashlangHostEnvironment {
265        lashlang_host_environment_from_tool_catalog(
266            catalog,
267            self.abilities,
268            self.language_features,
269            self.resources.clone(),
270        )
271    }
272}
273
274pub fn lashlang_host_environment_from_tool_catalog(
275    catalog: &lash_core::ToolCatalog,
276    abilities: LashlangAbilities,
277    language_features: LashlangLanguageFeatures,
278    host_resources: LashlangHostCatalog,
279) -> LashlangHostEnvironment {
280    let mut resources = lashlang_resources_from_tool_catalog(catalog);
281    resources.extend(host_resources);
282    if abilities.triggers {
283        lashlang::add_trigger_resource_operations(&mut resources);
284    }
285    LashlangHostEnvironment::new(resources, abilities).with_language_features(language_features)
286}
287
288pub fn lashlang_resources_from_tool_catalog(
289    catalog: &lash_core::ToolCatalog,
290) -> LashlangHostCatalog {
291    let mut host_catalog = LashlangHostCatalog::new();
292    for entry in catalog.tools.iter() {
293        if entry.availability.is_callable() {
294            let lashlang_binding =
295                tool_lashlang_binding(&entry.manifest).executable_for(&entry.manifest.name);
296            host_catalog.add_module_operation(
297                lashlang_binding.module_path.iter().map(String::as_str),
298                lashlang_binding.authority_type.clone(),
299                lashlang_binding.operation.clone(),
300                entry.manifest.name.clone(),
301                lashlang::TypeExpr::Any,
302                lashlang::TypeExpr::Any,
303            );
304        }
305    }
306    host_catalog
307}
308
309pub fn lashlang_host_environment_satisfies_requirements(
310    required: &lashlang::HostRequirements,
311    current: &LashlangHostEnvironment,
312) -> Result<(), String> {
313    let abilities = required.abilities;
314    let current_abilities = current.abilities;
315    if abilities.processes && !current_abilities.processes {
316        return Err("processes are not available".to_string());
317    }
318    if abilities.sleep && !current_abilities.sleep {
319        return Err("sleep is not available".to_string());
320    }
321    if abilities.process_signals && !current_abilities.process_signals {
322        return Err("process signals are not available".to_string());
323    }
324    if abilities.triggers && !current_abilities.triggers {
325        return Err("triggers are not available".to_string());
326    }
327    if required.language_features.label_annotations && !current.language_features.label_annotations
328    {
329        return Err("label annotations are not available".to_string());
330    }
331
332    for (_, module) in required.resources.module_instances() {
333        let current_module = current
334            .resources
335            .resolve_module_path(&module.path)
336            .ok_or_else(|| format!("module `{}` is not available", module.alias))?;
337        if current_module.resource_type != module.resource_type {
338            return Err(format!(
339                "module `{}` has type `{}`, expected `{}`",
340                module.alias, current_module.resource_type, module.resource_type
341            ));
342        }
343        for (operation, required_binding) in &module.operations {
344            match current.resources.resolve_module_operation(
345                &module.resource_type,
346                &module.alias,
347                operation,
348            ) {
349                Some(current_binding) if current_binding == required_binding => {}
350                Some(current_binding) => {
351                    return Err(format!(
352                        "module `{}` operation `{operation}` resolves to `{}`, expected `{}`",
353                        module.alias,
354                        current_binding.host_operation,
355                        required_binding.host_operation
356                    ));
357                }
358                None => {
359                    return Err(format!(
360                        "module `{}` does not expose operation `{operation}`",
361                        module.alias
362                    ));
363                }
364            }
365        }
366    }
367
368    for (resource_type, required_type) in required.resources.resource_types() {
369        if !current.resources.has_resource_type(resource_type) {
370            return Err(format!("resource type `{resource_type}` is not available"));
371        }
372        for (operation, required_binding) in &required_type.operations {
373            let current_binding = current
374                .resources
375                .resolve_operation(resource_type, operation)
376                .ok_or_else(|| {
377                    format!(
378                        "resource type `{resource_type}` does not expose operation `{operation}`"
379                    )
380                })?;
381            if current_binding.input_ty != required_binding.input_ty {
382                return Err(format!(
383                    "resource type `{resource_type}` operation `{operation}` has incompatible input type"
384                ));
385            }
386            if current_binding.output_ty != required_binding.output_ty {
387                return Err(format!(
388                    "resource type `{resource_type}` operation `{operation}` has incompatible output type"
389                ));
390            }
391        }
392    }
393    for (name, required_data_type) in required.resources.named_data_types() {
394        let current_data_type = current
395            .resources
396            .resolve_named_data_type(name)
397            .ok_or_else(|| format!("host data type `{name}` is not available"))?;
398        if current_data_type != required_data_type {
399            return Err(format!(
400                "host data type `{name}` has incompatible structure"
401            ));
402        }
403    }
404    for (path, required_binding) in required.resources.value_constructors() {
405        let current_binding = current
406            .resources
407            .resolve_value_constructor(&path.split('.').collect::<Vec<_>>())
408            .ok_or_else(|| format!("value constructor `{path}` is not available"))?;
409        if current_binding.input_ty != required_binding.input_ty {
410            return Err(format!(
411                "value constructor `{path}` has incompatible input type"
412            ));
413        }
414        if current_binding.output_ty != required_binding.output_ty {
415            return Err(format!(
416                "value constructor `{path}` has incompatible output type"
417            ));
418        }
419    }
420    for (source_ty, required_binding) in required.resources.trigger_sources() {
421        let current_binding = current
422            .resources
423            .resolve_trigger_source(source_ty)
424            .ok_or_else(|| format!("trigger source type `{source_ty}` is not available"))?;
425        if current_binding != required_binding {
426            return Err(format!(
427                "trigger source type `{source_ty}` has incompatible event type"
428            ));
429        }
430    }
431
432    Ok(())
433}
434
435#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
436pub struct LashlangProcessInput {
437    pub module_ref: lashlang::ModuleRef,
438    pub process_ref: lashlang::ProcessRef,
439    pub host_requirements_ref: lashlang::HostRequirementsRef,
440    pub process_name: String,
441    #[serde(default)]
442    pub args: serde_json::Map<String, serde_json::Value>,
443}
444
445impl LashlangProcessInput {
446    pub fn into_process_input(self) -> Result<lash_core::ProcessInput, serde_json::Error> {
447        Ok(lash_core::ProcessInput::Engine {
448            kind: LASHLANG_ENGINE_KIND.to_string(),
449            payload: serde_json::to_value(self)?,
450        })
451    }
452
453    pub fn from_payload(payload: serde_json::Value) -> Result<Self, serde_json::Error> {
454        serde_json::from_value(payload)
455    }
456
457    pub fn definition(&self) -> serde_json::Value {
458        serde_json::json!({
459            "module_ref": self.module_ref,
460            "process_ref": self.process_ref,
461            "host_requirements_ref": self.host_requirements_ref,
462            "process_name": self.process_name,
463        })
464    }
465}
466
467#[derive(Clone, Debug)]
468pub struct PreparedLashlangProcessStart {
469    pub registration: lash_core::ProcessRegistration,
470    pub label: Option<String>,
471}
472
473pub async fn prepare_lashlang_process_start(
474    artifact_store: Arc<dyn LashlangArtifactStore>,
475    start: lashlang::ProcessStart,
476) -> Result<PreparedLashlangProcessStart, String> {
477    let display_name = Some(start.process_name.clone());
478    let artifact = artifact_store
479        .get_module_artifact(&start.module_ref)
480        .await
481        .map_err(|err| format!("failed to load lashlang module artifact: {err}"))?
482        .ok_or_else(|| {
483            format!(
484                "missing lashlang module artifact `{}` for process `{}`",
485                start.module_ref, start.process_name
486            )
487        })?;
488    if artifact.host_requirements_ref != start.host_requirements_ref {
489        return Err(format!(
490            "lashlang module artifact `{}` host requirements mismatch: process requested {}, artifact has {}",
491            start.module_ref, start.host_requirements_ref, artifact.host_requirements_ref
492        ));
493    }
494    if artifact.process_ref(&start.process_name) != Some(&start.process_ref) {
495        return Err(format!(
496            "lashlang module artifact `{}` does not export process `{}` as requested ref {:?}",
497            start.module_ref, start.process_name, start.process_ref
498        ));
499    }
500    let args = match serde_json::to_value(lashlang::Value::Record(Arc::new(start.args)))
501        .map_err(|err| format!("failed to serialize process args: {err}"))?
502    {
503        serde_json::Value::Object(map) => map,
504        _ => return Err("process args must serialize as a record".to_string()),
505    };
506    let signal_event_types = artifact
507        .canonical_ir
508        .process(&start.process_name)
509        .map(lashlang_process_signal_event_types)
510        .unwrap_or_default();
511    let process_input = LashlangProcessInput {
512        module_ref: start.module_ref,
513        process_ref: start.process_ref,
514        host_requirements_ref: start.host_requirements_ref,
515        process_name: start.process_name,
516        args,
517    };
518    let identity = lashlang_process_identity(&process_input);
519    let process_input = process_input
520        .into_process_input()
521        .map_err(|err| format!("failed to encode process input: {err}"))?;
522    let process_id = format!("process:{}", uuid::Uuid::new_v4());
523    let registration = lash_core::ProcessRegistration::new(
524        process_id,
525        process_input,
526        lash_core::ProcessProvenance::host(),
527    )
528    .with_identity(identity)
529    .with_extra_event_types(
530        lashlang_process_event_types()
531            .into_iter()
532            .chain(signal_event_types),
533    );
534    Ok(PreparedLashlangProcessStart {
535        registration,
536        label: display_name,
537    })
538}
539
540pub fn resolve_lashlang_module_operation(
541    host_environment: &lashlang::LashlangHostEnvironment,
542    receiver: &lashlang::ResourceHandle,
543    operation: &str,
544) -> Result<String, lashlang::ExecutionHostError> {
545    host_environment
546        .resources
547        .resolve_module_operation(&receiver.resource_type, &receiver.alias, operation)
548        .map(|binding| binding.host_operation.clone())
549        .ok_or_else(|| {
550            lashlang::ExecutionHostError::new(format!(
551                "module `{}` of type `{}` does not expose operation `{operation}`",
552                receiver.alias, receiver.resource_type
553            ))
554        })
555}
556
557fn lashlang_process_identity(input: &LashlangProcessInput) -> lash_core::ProcessIdentity {
558    lash_core::ProcessIdentity::new(LASHLANG_ENGINE_KIND)
559        .with_label(Some(input.process_name.clone()))
560        .with_definition(Some(input.definition()))
561}
562
563#[derive(Clone)]
564pub struct LashlangProcessEngine {
565    artifact_store: Arc<dyn LashlangArtifactStore>,
566    process_cache: Arc<Mutex<CompiledProcessCache>>,
567    surface: LashlangSurface,
568    execution_sink: Option<Arc<dyn lash_trace::TraceSink>>,
569    trace_context: lash_trace::TraceContext,
570}
571
572impl LashlangProcessEngine {
573    pub fn new(artifact_store: Arc<dyn LashlangArtifactStore>, surface: LashlangSurface) -> Self {
574        Self {
575            artifact_store,
576            process_cache: Arc::new(Mutex::new(CompiledProcessCache::new())),
577            surface,
578            execution_sink: None,
579            trace_context: lash_trace::TraceContext::default(),
580        }
581    }
582
583    pub fn in_memory(surface: LashlangSurface) -> Self {
584        Self::new(
585            lashlang::global_in_memory_lashlang_artifact_store(),
586            surface,
587        )
588    }
589
590    pub fn with_execution_trace(
591        mut self,
592        sink: Option<Arc<dyn lash_trace::TraceSink>>,
593        trace_context: lash_trace::TraceContext,
594    ) -> Self {
595        self.execution_sink = sink;
596        self.trace_context = trace_context;
597        self
598    }
599
600    pub fn artifact_store(&self) -> Arc<dyn LashlangArtifactStore> {
601        Arc::clone(&self.artifact_store)
602    }
603}
604
605#[async_trait::async_trait]
606impl lash_core::ProcessEngine for LashlangProcessEngine {
607    fn kind(&self) -> &'static str {
608        LASHLANG_ENGINE_KIND
609    }
610
611    async fn validate_start(
612        &self,
613        context: lash_core::ProcessEngineValidationContext<'_>,
614        payload: &serde_json::Value,
615        _env_spec: Option<&lash_core::ProcessExecutionEnvSpec>,
616    ) -> Result<(), lash_core::PluginError> {
617        let input: LashlangProcessInput =
618            serde_json::from_value(payload.clone()).map_err(|err| {
619                lash_core::PluginError::Session(format!("invalid lashlang process payload: {err}"))
620            })?;
621        let artifact = self
622            .artifact_store
623            .get_module_artifact(&input.module_ref)
624            .await
625            .map_err(|err| lash_core::PluginError::Session(format!("load module artifact: {err}")))?
626            .ok_or_else(|| {
627                lash_core::PluginError::Session(format!(
628                    "missing lashlang module artifact `{}`",
629                    input.module_ref
630                ))
631            })?;
632        if artifact.host_requirements_ref != input.host_requirements_ref {
633            return Err(lash_core::PluginError::Session(format!(
634                "lashlang process `{}` requested surface {}, artifact has {}",
635                input.process_name, input.host_requirements_ref, artifact.host_requirements_ref
636            )));
637        }
638        if artifact.process_ref(&input.process_name) != Some(&input.process_ref) {
639            return Err(lash_core::PluginError::Session(format!(
640                "lashlang module `{}` does not export process `{}` as requested ref {:?}",
641                input.module_ref, input.process_name, input.process_ref
642            )));
643        }
644        let surface = self
645            .surface
646            .clone()
647            .for_process_registry(context.process_registry_available());
648        let host_environment = surface.host_environment(context.tool_catalog());
649        if let Err(err) = lashlang_host_environment_satisfies_requirements(
650            &artifact.host_requirements,
651            &host_environment,
652        ) {
653            return Err(lash_core::PluginError::Session(format!(
654                "lashlang process `{}` is incompatible with this host surface: {err}",
655                input.process_name
656            )));
657        }
658        Ok(())
659    }
660
661    async fn run(
662        &self,
663        context: lash_core::ProcessEngineRunContext<'_>,
664        payload: serde_json::Value,
665    ) -> lash_core::ProcessAwaitOutput {
666        process::run_lashlang_process(self.clone(), context, payload).await
667    }
668
669    fn identity(&self, payload: &serde_json::Value) -> lash_core::ProcessIdentity {
670        match LashlangProcessInput::from_payload(payload.clone()) {
671            Ok(input) => lashlang_process_identity(&input),
672            Err(_) => lash_core::ProcessIdentity::new(LASHLANG_ENGINE_KIND),
673        }
674    }
675}
676
677mod bridge;
678mod process;
679
680pub use bridge::{
681    lashlang_value_to_json, process_event_payload, protocol_tool_output_to_lashlang_value,
682    protocol_tool_reply_to_lashlang_value, sleep_duration_ms,
683};
684pub use process::{
685    lashlang_process_event_types, lashlang_process_signal_event_types, lashlang_type_expr_schema,
686};
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691
692    #[test]
693    fn process_input_serializes_as_generic_engine_payload() {
694        let hash = lashlang::ContentHash::new("abc123");
695        let input = LashlangProcessInput {
696            module_ref: lashlang::ModuleRef::new(&hash),
697            process_ref: lashlang::ProcessRef::new(hash.clone(), 7),
698            host_requirements_ref: lashlang::HostRequirementsRef::new(&hash),
699            process_name: "main".to_string(),
700            args: serde_json::Map::from_iter([("prompt".to_string(), serde_json::json!("go"))]),
701        };
702
703        let process_input = input
704            .clone()
705            .into_process_input()
706            .expect("lashlang process input serializes");
707
708        let lash_core::ProcessInput::Engine { kind, payload } = process_input else {
709            panic!("lashlang runtime must use the generic engine process input");
710        };
711        assert_eq!(kind, LASHLANG_ENGINE_KIND);
712        assert_eq!(
713            LashlangProcessInput::from_payload(payload)
714                .expect("engine payload decodes")
715                .process_name,
716            input.process_name
717        );
718    }
719
720    #[test]
721    fn tool_binding_defaults_remain_lashlang_local_policy() {
722        let tool = lash_core::ToolDefinition::raw_named(
723            "read_file",
724            "read a file",
725            lash_core::ToolDefinition::default_input_schema(),
726            serde_json::Value::Null,
727        );
728
729        let binding = tool_lashlang_binding(&tool.manifest).executable_for(&tool.manifest.name);
730
731        assert_eq!(binding.module_path, vec!["tools"]);
732        assert_eq!(binding.operation, "read.file");
733        assert_eq!(binding.authority_type, "Tools");
734        assert_eq!(binding.call_path(), "tools.read.file");
735    }
736
737    #[test]
738    fn explicit_tool_binding_attaches_lashlang_metadata() {
739        let tool = lash_core::ToolDefinition::raw_named(
740            "read_file",
741            "read a file",
742            lash_core::ToolDefinition::default_input_schema(),
743            serde_json::Value::Null,
744        )
745        .with_lashlang_binding(
746            LashlangToolBinding::new(["fs"], "read")
747                .with_authority_type("Filesystem")
748                .with_aliases(["cat"]),
749        );
750
751        let binding = tool_lashlang_binding(&tool.manifest).executable_for(&tool.manifest.name);
752
753        assert_eq!(binding.module_path, vec!["fs"]);
754        assert_eq!(binding.operation, "read");
755        assert_eq!(binding.authority_type, "Filesystem");
756        assert_eq!(binding.aliases, vec!["cat"]);
757    }
758
759    #[test]
760    fn surface_merges_plugin_extensions() {
761        let contribution = LashlangSurfaceContribution::new(
762            LashlangAbilities::default().with_processes(),
763            LashlangLanguageFeatures::default().with_label_annotations(),
764            LashlangHostCatalog::tool_default(["lookup"]),
765        );
766        let extensions = lash_core::PluginExtensions::from_contributions([
767            lash_core::PluginExtensionContribution::new(
768                LASHLANG_SURFACE_EXTENSION_ID,
769                contribution,
770            )
771            .expect("extension payload serializes"),
772        ]);
773
774        let surface = LashlangSurface::default()
775            .with_plugin_extensions(&extensions)
776            .expect("lashlang surface extension merges");
777        let environment = surface.host_environment(&lash_core::ToolCatalog::default());
778
779        assert!(environment.abilities.sleep);
780        assert!(environment.abilities.processes);
781        assert!(environment.language_features.label_annotations);
782        assert!(
783            environment
784                .resources
785                .resolve_module_operation("Tools", "tools", "lookup")
786                .is_some()
787        );
788    }
789}