Skip to main content

lash_core/session/
execution_context.rs

1use std::sync::Arc;
2
3use tokio::sync::mpsc::Sender;
4use tokio_util::sync::CancellationToken;
5
6use crate::tool_dispatch::ToolDispatchContext;
7use crate::{TurnActivity, TurnActivityId, TurnEvent};
8
9pub(crate) fn lashlang_surface_from_tool_surface(
10    surface: &crate::ToolSurface,
11    abilities: lashlang::LashlangAbilities,
12    language_features: lashlang::LashlangLanguageFeatures,
13    host_resources: lashlang::ResourceCatalog,
14) -> lashlang::LashlangSurface {
15    let mut resources = lashlang_resources_from_tool_surface(surface);
16    resources.extend(host_resources);
17    lashlang::LashlangSurface::new(resources, abilities).with_language_features(language_features)
18}
19
20pub(crate) fn lashlang_resources_from_tool_surface(
21    surface: &crate::ToolSurface,
22) -> lashlang::ResourceCatalog {
23    let mut catalog = lashlang::ResourceCatalog::new();
24    for entry in surface.tools.iter() {
25        if entry.availability.is_callable() {
26            let agent_surface = entry
27                .manifest
28                .agent_surface
29                .executable_for(&entry.manifest.name);
30            catalog.add_module_operation(
31                agent_surface.module_path.iter().map(String::as_str),
32                agent_surface.authority_type.clone(),
33                agent_surface.operation.clone(),
34                entry.manifest.name.clone(),
35                lashlang::TypeExpr::Any,
36                lashlang::TypeExpr::Any,
37            );
38        }
39    }
40    catalog
41}
42
43#[derive(Clone)]
44pub struct RuntimeExecutionContext<'run> {
45    pub(super) session_id: String,
46    pub(super) dispatch: Arc<ToolDispatchContext<'run>>,
47    lashlang_abilities: lashlang::LashlangAbilities,
48    lashlang_language_features: lashlang::LashlangLanguageFeatures,
49    lashlang_surface: lashlang::LashlangSurface,
50    lashlang_artifact_store: Arc<dyn lashlang::LashlangArtifactStore>,
51    attachment_store: Arc<dyn crate::AttachmentStore>,
52    chronological_projection: Arc<crate::ChronologicalProjection>,
53    protocol_extension: Option<crate::ProtocolTurnExtensionHandle>,
54    turn_context: crate::TurnContext,
55    pub(super) parent_invocation: Option<crate::RuntimeInvocation>,
56    lashlang_execution_sink: Option<Arc<dyn lash_trace::TraceSink>>,
57    lashlang_execution_context: lash_trace::TraceContext,
58    pub(super) turn_event_tx: Option<Sender<TurnActivity>>,
59    pub(super) cancellation_token: Option<CancellationToken>,
60}
61
62impl<'run> RuntimeExecutionContext<'run> {
63    pub(crate) fn drain_tool_host_event_outcomes(
64        &self,
65    ) -> Result<Vec<crate::tool_dispatch::ToolHostEventEffectOutcome>, crate::PluginError> {
66        self.dispatch
67            .host_event_outcomes
68            .drain()
69            .map_err(crate::PluginError::Session)
70    }
71
72    pub(super) fn process_scope(
73        &self,
74        parent_invocation: Option<crate::RuntimeInvocation>,
75    ) -> crate::ProcessOpScope<'_> {
76        crate::ProcessOpScope::new(self.dispatch.effect_controller.scoped())
77            .with_parent_invocation(parent_invocation)
78            .with_agent_frame_id(Some(self.dispatch.agent_frame_id.clone()))
79    }
80
81    #[allow(
82        clippy::too_many_arguments,
83        reason = "code execution bridge carries explicit per-turn runtime dependencies"
84    )]
85    pub(crate) fn new(
86        session_id: String,
87        dispatch: Arc<ToolDispatchContext<'run>>,
88        lashlang_abilities: lashlang::LashlangAbilities,
89        lashlang_language_features: lashlang::LashlangLanguageFeatures,
90        lashlang_artifact_store: Arc<dyn lashlang::LashlangArtifactStore>,
91        attachment_store: Arc<dyn crate::AttachmentStore>,
92        chronological_projection: Arc<crate::ChronologicalProjection>,
93        protocol_extension: Option<crate::ProtocolTurnExtensionHandle>,
94        turn_context: crate::TurnContext,
95    ) -> Self {
96        let lashlang_surface = lashlang_surface_from_tool_surface(
97            &dispatch.surface,
98            lashlang_abilities,
99            lashlang_language_features,
100            dispatch.plugins.lashlang_resources(),
101        );
102        Self {
103            session_id,
104            dispatch,
105            lashlang_abilities,
106            lashlang_language_features,
107            lashlang_surface,
108            lashlang_artifact_store,
109            attachment_store,
110            chronological_projection,
111            protocol_extension,
112            turn_context,
113            parent_invocation: None,
114            lashlang_execution_sink: None,
115            lashlang_execution_context: lash_trace::TraceContext::default(),
116            turn_event_tx: None,
117            cancellation_token: None,
118        }
119    }
120
121    pub fn session_id(&self) -> &str {
122        &self.session_id
123    }
124
125    pub fn attachment_store(&self) -> Arc<dyn crate::AttachmentStore> {
126        Arc::clone(&self.attachment_store)
127    }
128
129    pub async fn put_lashlang_module_artifact(
130        &self,
131        artifact: &lashlang::ModuleArtifact,
132    ) -> Result<(), String> {
133        self.lashlang_artifact_store
134            .put_module_artifact(artifact)
135            .await
136            .map_err(|err| err.to_string())
137    }
138
139    pub fn chronological_projection(&self) -> Arc<crate::ChronologicalProjection> {
140        Arc::clone(&self.chronological_projection)
141    }
142
143    pub fn protocol_extension<T: 'static>(&self) -> Option<&T> {
144        self.protocol_extension
145            .as_ref()
146            .and_then(|extension| extension.as_any().downcast_ref::<T>())
147    }
148
149    pub fn turn_context(&self) -> &crate::TurnContext {
150        &self.turn_context
151    }
152
153    pub(crate) fn session_graph_service(&self) -> &dyn crate::plugin::SessionGraphService {
154        self.dispatch.session_graph.as_ref()
155    }
156
157    pub(super) async fn emit_turn_activity(
158        &self,
159        correlation_id: TurnActivityId,
160        event: TurnEvent,
161    ) {
162        if let Some(tx) = &self.turn_event_tx {
163            let _ = tx.send(TurnActivity::new(correlation_id, event)).await;
164        }
165    }
166
167    pub(crate) fn with_turn_event_sender(mut self, turn_event_tx: Sender<TurnActivity>) -> Self {
168        self.turn_event_tx = Some(turn_event_tx);
169        self
170    }
171
172    pub(crate) fn with_parent_invocation(mut self, metadata: crate::RuntimeInvocation) -> Self {
173        self.parent_invocation = Some(metadata);
174        self
175    }
176
177    pub(crate) fn with_lashlang_execution_trace(
178        mut self,
179        sink: Option<Arc<dyn lash_trace::TraceSink>>,
180        context: lash_trace::TraceContext,
181    ) -> Self {
182        self.lashlang_execution_sink = sink;
183        self.lashlang_execution_context = context;
184        self
185    }
186
187    pub fn parent_invocation(&self) -> Option<&crate::RuntimeInvocation> {
188        self.parent_invocation.as_ref()
189    }
190
191    pub fn lashlang_execution_sink(&self) -> Option<Arc<dyn lash_trace::TraceSink>> {
192        self.lashlang_execution_sink.clone()
193    }
194
195    pub fn lashlang_execution_context(&self) -> &lash_trace::TraceContext {
196        &self.lashlang_execution_context
197    }
198
199    pub(crate) fn with_cancellation_token(mut self, cancellation_token: CancellationToken) -> Self {
200        self.cancellation_token = Some(cancellation_token);
201        self
202    }
203
204    pub(crate) fn tool_scheduling(&self, name: &str) -> crate::ToolScheduling {
205        crate::tool_dispatch::resolve_tool_scheduling(&self.dispatch, name)
206    }
207
208    pub fn callable_tool_manifest(&self, name: &str) -> Option<crate::ToolManifest> {
209        crate::tool_dispatch::resolve_callable_manifest(&self.dispatch, name)
210    }
211
212    pub fn callable_tool_manifest_by_id(&self, id: &crate::ToolId) -> Option<crate::ToolManifest> {
213        crate::tool_dispatch::resolve_callable_manifest_by_id(&self.dispatch, id)
214    }
215
216    pub fn resolve_lashlang_host_operation(
217        &self,
218        receiver: &lashlang::ResourceHandle,
219        operation: &str,
220    ) -> Result<String, String> {
221        self.lashlang_surface
222            .resources
223            .resolve_module_operation(&receiver.resource_type, &receiver.alias, operation)
224            .map(|binding| binding.host_operation.clone())
225            .ok_or_else(|| {
226                format!(
227                    "module `{}` of type `{}` does not expose operation `{operation}`",
228                    receiver.alias, receiver.resource_type
229                )
230            })
231    }
232
233    pub async fn prepare_lashlang_process_start(
234        &self,
235        start: lashlang::ProcessStart,
236    ) -> Result<(crate::ProcessRegistration, Option<String>), String> {
237        let display_name = Some(start.process_name.clone());
238        let artifact = self
239            .lashlang_artifact_store
240            .get_module_artifact(&start.module_ref)
241            .await
242            .map_err(|err| format!("failed to load lashlang module artifact: {err}"))?
243            .ok_or_else(|| {
244                format!(
245                    "missing lashlang module artifact `{}` for process `{}`",
246                    start.module_ref, start.process_name
247                )
248            })?;
249        if artifact.required_surface_ref != start.required_surface_ref {
250            return Err(format!(
251                "lashlang module artifact `{}` required surface mismatch: process requested {}, artifact has {}",
252                start.module_ref, start.required_surface_ref, artifact.required_surface_ref
253            ));
254        }
255        if artifact.process_ref(&start.process_name) != Some(&start.process_ref) {
256            return Err(format!(
257                "lashlang module artifact `{}` does not export process `{}` as requested ref {:?}",
258                start.module_ref, start.process_name, start.process_ref
259            ));
260        }
261        let args = match serde_json::to_value(lashlang::Value::Record(Arc::new(start.args)))
262            .map_err(|err| format!("failed to serialize process args: {err}"))?
263        {
264            serde_json::Value::Object(map) => map,
265            _ => return Err("process args must serialize as a record".to_string()),
266        };
267        let process_id = format!("process:{}", uuid::Uuid::new_v4());
268        let registration = crate::ProcessRegistration::new(
269            process_id,
270            crate::ProcessInput::LashlangProcess {
271                module_ref: start.module_ref,
272                process_ref: start.process_ref,
273                required_surface_ref: start.required_surface_ref,
274                process_name: start.process_name,
275                args,
276            },
277        )
278        .with_extra_event_types(crate::lashlang_process_event_types());
279        Ok((registration, display_name))
280    }
281
282    pub fn lashlang_surface(&self) -> &lashlang::LashlangSurface {
283        &self.lashlang_surface
284    }
285
286    pub fn lashlang_abilities(&self) -> lashlang::LashlangAbilities {
287        self.lashlang_abilities
288    }
289
290    pub fn lashlang_language_features(&self) -> lashlang::LashlangLanguageFeatures {
291        self.lashlang_language_features
292    }
293
294    pub fn link_lashlang_module(
295        &self,
296        program: lashlang::Program,
297    ) -> Result<lashlang::LinkedModule, String> {
298        lashlang::LinkedModule::link(program, self.lashlang_surface())
299            .map_err(|err| err.to_string())
300    }
301
302    pub async fn perform_lashlang_trigger_operation(
303        &self,
304        operation: &str,
305        payload: serde_json::Value,
306    ) -> Result<serde_json::Value, String> {
307        match lashlang::TriggerHostOperation::from_host_operation(operation) {
308            Some(lashlang::TriggerHostOperation::Register) => self
309                .register_host_event_subscription(payload)
310                .await
311                .map_err(|err| err.to_string()),
312            Some(lashlang::TriggerHostOperation::List) => self
313                .list_host_event_subscriptions(payload)
314                .await
315                .map_err(|err| err.to_string()),
316            Some(lashlang::TriggerHostOperation::Cancel) => self
317                .cancel_host_event_subscription(payload)
318                .await
319                .map_err(|err| err.to_string()),
320            None => Err(format!("unknown trigger operation `{operation}`")),
321        }
322    }
323
324    async fn register_host_event_subscription(
325        &self,
326        payload: serde_json::Value,
327    ) -> Result<serde_json::Value, crate::PluginError> {
328        let router = self.dispatch.host_event_router.as_ref().ok_or_else(|| {
329            crate::PluginError::Session(
330                "host event store is unavailable in this runtime".to_string(),
331            )
332        })?;
333        let request = lashlang::TriggerRegistrationRequest::decode(&payload)
334            .map_err(|err| crate::PluginError::Session(err.to_string()))?;
335        let source_type = request.source.source_type.clone();
336        let source_value = request.source.value.clone();
337        let source = request.source.to_json();
338        let event_type = lashlang::event_type_for_source(
339            &self.dispatch.plugins.lashlang_resources(),
340            &source_type,
341        )
342        .map_err(|err| crate::PluginError::Session(err.to_string()))?;
343        let validation = crate::plugin::validate_target_process(
344            &request.target,
345            &event_type,
346            &request.inputs,
347            self.lashlang_artifact_store.as_ref(),
348        )
349        .await?;
350        let store = router.store();
351        let source_key = store
352            .source_key_for_subscription(&source_type, &source_value)
353            .await?;
354        let record = store
355            .register_subscription(crate::TriggerSubscriptionDraft {
356                session_id: self.session_id.clone(),
357                name: request.name,
358                source_type,
359                source_key,
360                source,
361                event_ty: validation.event_ty,
362                module_ref: request.target.module_ref,
363                required_surface_ref: request.target.required_surface_ref,
364                process_ref: request.target.process_ref,
365                process_name: request.target.process_name,
366                input_template: validation.inputs,
367            })
368            .await?;
369        Ok(crate::plugin::trigger_handle_json(&record.handle))
370    }
371
372    async fn list_host_event_subscriptions(
373        &self,
374        payload: serde_json::Value,
375    ) -> Result<serde_json::Value, crate::PluginError> {
376        let router = self.dispatch.host_event_router.as_ref().ok_or_else(|| {
377            crate::PluginError::Session(
378                "host event store is unavailable in this runtime".to_string(),
379            )
380        })?;
381        let request = lashlang::TriggerListRequest::decode(&payload)
382            .map_err(|err| crate::PluginError::Session(err.to_string()))?;
383        let mut filter = crate::TriggerSubscriptionFilter::for_session(&self.session_id);
384        filter.target = request.target;
385        filter.name = request.name;
386        filter.source_type = request.source_type;
387        filter.enabled = request.enabled;
388        let registrations = router
389            .store()
390            .list_subscriptions(filter)
391            .await?
392            .iter()
393            .map(crate::TriggerRegistration::from)
394            .collect::<Vec<_>>();
395        serde_json::to_value(registrations).map_err(|err| {
396            crate::PluginError::Session(format!("failed to encode trigger registrations: {err}"))
397        })
398    }
399
400    async fn cancel_host_event_subscription(
401        &self,
402        payload: serde_json::Value,
403    ) -> Result<serde_json::Value, crate::PluginError> {
404        let router = self.dispatch.host_event_router.as_ref().ok_or_else(|| {
405            crate::PluginError::Session(
406                "host event store is unavailable in this runtime".to_string(),
407            )
408        })?;
409        let request = lashlang::TriggerCancelRequest::decode(&payload)
410            .map_err(|err| crate::PluginError::Session(err.to_string()))?;
411        let changed = router
412            .store()
413            .cancel_subscription(&self.session_id, &request.handle)
414            .await?;
415        Ok(serde_json::json!(changed))
416    }
417
418    pub fn tool_argument_projection_policy(
419        &self,
420        name: &str,
421    ) -> crate::ToolArgumentProjectionPolicy {
422        crate::tool_dispatch::resolve_tool_argument_projection_policy(&self.dispatch, name)
423    }
424
425    pub async fn start_lashlang_process(
426        &self,
427        registration: crate::ProcessRegistration,
428        label: Option<String>,
429    ) -> crate::ToolInvocationReply {
430        let process_id = registration.id.clone();
431        match self
432            .dispatch
433            .processes
434            .start(
435                &self.session_id,
436                registration,
437                crate::ProcessStartOptions::new()
438                    .with_descriptor(crate::ProcessHandleDescriptor::new(Some("lashlang"), label)),
439                self.process_scope(self.parent_invocation.clone()),
440            )
441            .await
442        {
443            Ok(_) => crate::ToolInvocationReply::success(
444                crate::lashlang_bridge::process_handle_json(&process_id),
445            ),
446            Err(err) => crate::ToolInvocationReply::error(serde_json::json!(err.to_string())),
447        }
448    }
449
450    pub async fn sleep_lashlang(
451        &self,
452        scope: &str,
453        sequence: u64,
454        duration_ms: u64,
455    ) -> Result<(), crate::RuntimeEffectControllerError> {
456        let cancellation = self.cancellation_token.clone().unwrap_or_default();
457        let invocation = crate::runtime::causal::lashlang_sleep_invocation(
458            &self.session_id,
459            self.parent_invocation.as_ref(),
460            scope,
461            sequence,
462        );
463        let outcome = self
464            .dispatch
465            .effect_controller
466            .controller()
467            .execute_effect(
468                crate::RuntimeEffectEnvelope::new(
469                    invocation,
470                    crate::RuntimeEffectCommand::Sleep { duration_ms },
471                ),
472                crate::RuntimeEffectLocalExecutor::sleep(cancellation),
473            )
474            .await?;
475        match outcome {
476            crate::RuntimeEffectOutcome::Sleep => Ok(()),
477            other => Err(crate::RuntimeEffectControllerError::new(
478                "runtime_effect_wrong_outcome",
479                format!("expected sleep outcome, got {}", other.kind().as_str()),
480            )),
481        }
482    }
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488    use crate::tool_dispatch::ToolDispatchContext;
489    use crate::{ToolCall, ToolProvider, ToolResult};
490
491    struct NoopTools;
492
493    #[async_trait::async_trait]
494    impl ToolProvider for NoopTools {
495        fn tool_manifests(&self) -> Vec<crate::ToolManifest> {
496            Vec::new()
497        }
498
499        fn resolve_contract(&self, _name: &str) -> Option<Arc<crate::ToolContract>> {
500            None
501        }
502
503        async fn execute(&self, _call: ToolCall<'_>) -> ToolResult {
504            ToolResult::err_fmt("not used")
505        }
506    }
507
508    #[test]
509    fn tool_argument_projection_policy_resolves_from_active_surface_and_defaults_unknown() {
510        let tool = crate::ToolDefinition::raw(
511            "tool:seedy",
512            "seedy",
513            "Seed-aware",
514            crate::ToolDefinition::default_input_schema(),
515            serde_json::json!({ "type": "string" }),
516        )
517        .with_argument_projection(
518            crate::ToolArgumentProjectionPolicy::preserve_projected_refs_in_field("seed"),
519        );
520        let plugins = crate::plugin::PluginHost::empty()
521            .build_session("session", None)
522            .expect("plugin session");
523        let (event_tx, _event_rx) = tokio::sync::mpsc::channel(1);
524        let dispatch = Arc::new(ToolDispatchContext {
525            plugins,
526            tools: Arc::new(NoopTools),
527            surface: Arc::new(crate::ToolSurface::from_tools(
528                vec![tool.manifest()],
529                std::collections::BTreeMap::new(),
530            )),
531            sessions: Arc::new(crate::testing::MockSessionManager::default()),
532            session_lifecycle: Arc::new(crate::testing::MockSessionManager::default()),
533            session_graph: Arc::new(crate::testing::MockSessionManager::default()),
534            processes: Arc::new(crate::UnavailableProcessService),
535            process_cancel_ability: Arc::new(crate::DefaultProcessCancelAbility),
536            host_event_router: None,
537            effect_controller: crate::runtime::RuntimeEffectControllerHandle::shared(Arc::new(
538                crate::InlineRuntimeEffectController,
539            )),
540            direct_completions: crate::DirectCompletionClient::unavailable(
541                "direct completions are unavailable in this test context",
542            ),
543            parent_invocation: None,
544            session_id: "session".to_string(),
545            agent_frame_id: String::new(),
546            event_tx,
547            checkpoint_messages: crate::tool_dispatch::CheckpointMessageBuffer::default(),
548            host_event_outcomes: crate::tool_dispatch::ToolHostEventOutcomeBuffer::default(),
549            attachment_store: Arc::new(crate::InMemoryAttachmentStore::new()),
550            turn_context: crate::TurnContext::default(),
551        });
552        let ctx = RuntimeExecutionContext::new(
553            "session".to_string(),
554            dispatch,
555            Default::default(),
556            Default::default(),
557            Arc::new(lashlang::InMemoryLashlangArtifactStore::new()),
558            Arc::new(crate::InMemoryAttachmentStore::new()),
559            Arc::new(crate::ChronologicalProjection::default()),
560            None,
561            crate::TurnContext::default(),
562        );
563
564        assert_eq!(
565            ctx.tool_argument_projection_policy("seedy"),
566            crate::ToolArgumentProjectionPolicy::preserve_projected_refs_in_field("seed")
567        );
568        assert_eq!(
569            ctx.tool_argument_projection_policy("missing"),
570            crate::ToolArgumentProjectionPolicy::MaterializeProjectedValues
571        );
572    }
573
574    #[tokio::test]
575    async fn prepare_lashlang_process_start_captures_tool_ids_and_explicit_input() {
576        let tool = crate::ToolDefinition::raw(
577            "tool:alpha",
578            "alpha",
579            "Alpha tool.",
580            crate::ToolDefinition::default_input_schema(),
581            serde_json::json!({ "type": "object", "additionalProperties": true }),
582        );
583        let plugins = crate::plugin::PluginHost::empty()
584            .build_session("session", None)
585            .expect("plugin session");
586        let (event_tx, _event_rx) = tokio::sync::mpsc::channel(1);
587        let dispatch = Arc::new(ToolDispatchContext {
588            plugins,
589            tools: Arc::new(NoopTools),
590            surface: Arc::new(crate::ToolSurface::from_tools(
591                vec![tool.manifest()],
592                std::collections::BTreeMap::new(),
593            )),
594            sessions: Arc::new(crate::testing::MockSessionManager::default()),
595            session_lifecycle: Arc::new(crate::testing::MockSessionManager::default()),
596            session_graph: Arc::new(crate::testing::MockSessionManager::default()),
597            processes: Arc::new(crate::UnavailableProcessService),
598            process_cancel_ability: Arc::new(crate::DefaultProcessCancelAbility),
599            host_event_router: None,
600            effect_controller: crate::runtime::RuntimeEffectControllerHandle::shared(Arc::new(
601                crate::InlineRuntimeEffectController,
602            )),
603            direct_completions: crate::DirectCompletionClient::unavailable(
604                "direct completions are unavailable in this test context",
605            ),
606            parent_invocation: None,
607            session_id: "session".to_string(),
608            agent_frame_id: String::new(),
609            event_tx,
610            checkpoint_messages: crate::tool_dispatch::CheckpointMessageBuffer::default(),
611            host_event_outcomes: crate::tool_dispatch::ToolHostEventOutcomeBuffer::default(),
612            attachment_store: Arc::new(crate::InMemoryAttachmentStore::new()),
613            turn_context: crate::TurnContext::default(),
614        });
615        let ctx = RuntimeExecutionContext::new(
616            "session".to_string(),
617            dispatch,
618            lashlang::LashlangAbilities::default().with_processes(),
619            Default::default(),
620            Arc::new(lashlang::InMemoryLashlangArtifactStore::new()),
621            Arc::new(crate::InMemoryAttachmentStore::new()),
622            Arc::new(crate::ChronologicalProjection::default()),
623            None,
624            crate::TurnContext::default(),
625        );
626        let mut input = lashlang::Record::new();
627        input.insert("root".to_string(), lashlang::Value::String(".".into()));
628        let linked = ctx
629            .link_lashlang_module(
630                lashlang::parse("process scan(root: str) { finish root }").expect("process module"),
631            )
632            .expect("link process module");
633        ctx.put_lashlang_module_artifact(&linked.artifact)
634            .await
635            .expect("store module artifact");
636        let process_ref = linked
637            .artifact
638            .process_ref("scan")
639            .expect("scan process ref")
640            .clone();
641        let (registration, label) = ctx
642            .prepare_lashlang_process_start(lashlang::ProcessStart {
643                module_ref: linked.module_ref.clone(),
644                process_ref,
645                required_surface_ref: linked.required_surface_ref.clone(),
646                process_name: "scan".to_string(),
647                args: input,
648            })
649            .await
650            .expect("process start should prepare");
651
652        assert_eq!(label.as_deref(), Some("scan"));
653        assert!(
654            registration
655                .event_types
656                .iter()
657                .any(|event_type| event_type.name == "process.wake")
658        );
659        let crate::ProcessInput::LashlangProcess {
660            args, process_name, ..
661        } = registration.input.as_ref()
662        else {
663            panic!("expected lashlang process input");
664        };
665        assert_eq!(process_name, "scan");
666        assert_eq!(args.get("root"), Some(&serde_json::json!(".")));
667    }
668
669    #[test]
670    fn lashlang_surface_reflects_host_abilities() {
671        let tool = crate::ToolDefinition::raw(
672            "tool:alpha",
673            "alpha",
674            "Alpha tool.",
675            crate::ToolDefinition::default_input_schema(),
676            serde_json::json!({ "type": "object", "additionalProperties": true }),
677        );
678        let plugins = crate::plugin::PluginHost::empty()
679            .build_session("session", None)
680            .expect("plugin session");
681        let (event_tx, _event_rx) = tokio::sync::mpsc::channel(1);
682        let dispatch = Arc::new(ToolDispatchContext {
683            plugins,
684            tools: Arc::new(NoopTools),
685            surface: Arc::new(crate::ToolSurface::from_tools(
686                vec![tool.manifest()],
687                std::collections::BTreeMap::new(),
688            )),
689            sessions: Arc::new(crate::testing::MockSessionManager::default()),
690            session_lifecycle: Arc::new(crate::testing::MockSessionManager::default()),
691            session_graph: Arc::new(crate::testing::MockSessionManager::default()),
692            processes: Arc::new(crate::UnavailableProcessService),
693            process_cancel_ability: Arc::new(crate::DefaultProcessCancelAbility),
694            host_event_router: None,
695            effect_controller: crate::runtime::RuntimeEffectControllerHandle::shared(Arc::new(
696                crate::InlineRuntimeEffectController,
697            )),
698            direct_completions: crate::DirectCompletionClient::unavailable(
699                "direct completions are unavailable in this test context",
700            ),
701            parent_invocation: None,
702            session_id: "session".to_string(),
703            agent_frame_id: String::new(),
704            event_tx,
705            checkpoint_messages: crate::tool_dispatch::CheckpointMessageBuffer::default(),
706            host_event_outcomes: crate::tool_dispatch::ToolHostEventOutcomeBuffer::default(),
707            attachment_store: Arc::new(crate::InMemoryAttachmentStore::new()),
708            turn_context: crate::TurnContext::default(),
709        });
710        let ctx = RuntimeExecutionContext::new(
711            "session".to_string(),
712            dispatch,
713            lashlang::LashlangAbilities::default()
714                .with_sleep()
715                .with_processes()
716                .with_process_signals(),
717            Default::default(),
718            Arc::new(lashlang::InMemoryLashlangArtifactStore::new()),
719            Arc::new(crate::InMemoryAttachmentStore::new()),
720            Arc::new(crate::ChronologicalProjection::default()),
721            None,
722            crate::TurnContext::default(),
723        );
724
725        let surface = ctx.lashlang_surface();
726
727        assert!(std::ptr::eq(surface, ctx.lashlang_surface()));
728        assert!(surface.abilities.processes);
729        assert!(surface.abilities.sleep);
730        assert!(surface.abilities.process_signals);
731        assert!(!surface.abilities.triggers);
732        assert!(
733            surface
734                .resources
735                .resolve_operation("Tools", "alpha")
736                .is_some()
737        );
738    }
739
740    #[test]
741    fn lashlang_surface_reflects_host_resource_contributions() {
742        let mut resources = lashlang::ResourceCatalog::new();
743        resources
744            .add_trigger_source_constructor(
745                ["clock", "Alarm"],
746                lashlang::TypeExpr::Object(vec![lashlang::TypeField {
747                    name: "at".into(),
748                    ty: lashlang::TypeExpr::Str,
749                    optional: false,
750                }]),
751                lashlang::NamedDataType::object(
752                    "clock.Tick",
753                    vec![lashlang::TypeField {
754                        name: "fired_at".into(),
755                        ty: lashlang::TypeExpr::Str,
756                        optional: false,
757                    }],
758                )
759                .expect("valid clock tick type"),
760            )
761            .expect("valid clock trigger source");
762        let plugin_host = crate::plugin::PluginHost::empty();
763        let mut merged_resources = plugin_host.lashlang_resources();
764        merged_resources.extend(resources);
765        let plugins = plugin_host
766            .with_lashlang_resources(merged_resources)
767            .build_session("session", None)
768            .expect("plugin session");
769        let (event_tx, _event_rx) = tokio::sync::mpsc::channel(1);
770        let dispatch = Arc::new(ToolDispatchContext {
771            plugins,
772            tools: Arc::new(NoopTools),
773            surface: Arc::new(crate::ToolSurface::from_tools(
774                Vec::new(),
775                std::collections::BTreeMap::new(),
776            )),
777            sessions: Arc::new(crate::testing::MockSessionManager::default()),
778            session_lifecycle: Arc::new(crate::testing::MockSessionManager::default()),
779            session_graph: Arc::new(crate::testing::MockSessionManager::default()),
780            processes: Arc::new(crate::UnavailableProcessService),
781            process_cancel_ability: Arc::new(crate::DefaultProcessCancelAbility),
782            host_event_router: None,
783            effect_controller: crate::runtime::RuntimeEffectControllerHandle::shared(Arc::new(
784                crate::InlineRuntimeEffectController,
785            )),
786            direct_completions: crate::DirectCompletionClient::unavailable(
787                "direct completions are unavailable in this test context",
788            ),
789            parent_invocation: None,
790            session_id: "session".to_string(),
791            agent_frame_id: String::new(),
792            event_tx,
793            checkpoint_messages: crate::tool_dispatch::CheckpointMessageBuffer::default(),
794            host_event_outcomes: crate::tool_dispatch::ToolHostEventOutcomeBuffer::default(),
795            attachment_store: Arc::new(crate::InMemoryAttachmentStore::new()),
796            turn_context: crate::TurnContext::default(),
797        });
798        let ctx = RuntimeExecutionContext::new(
799            "session".to_string(),
800            dispatch,
801            lashlang::LashlangAbilities::default()
802                .with_processes()
803                .with_triggers(),
804            Default::default(),
805            Arc::new(lashlang::InMemoryLashlangArtifactStore::new()),
806            Arc::new(crate::InMemoryAttachmentStore::new()),
807            Arc::new(crate::ChronologicalProjection::default()),
808            None,
809            crate::TurnContext::default(),
810        );
811
812        let surface = ctx.lashlang_surface();
813
814        assert!(
815            surface
816                .resources
817                .resolve_value_constructor(&["clock", "Alarm"])
818                .is_some()
819        );
820        assert!(
821            surface
822                .resources
823                .resolve_trigger_source("clock.Alarm")
824                .is_some()
825        );
826        lashlang::LinkedModule::link(
827            lashlang::parse(
828                r#"
829                process remember(tick: clock.Tick) {
830                  finish true
831                }
832
833                source = clock.Alarm({ at: "08:00" })
834                await triggers.register({
835                  source: source,
836                  target: remember,
837                  inputs: { tick: trigger.event }
838                })?
839                "#,
840            )
841            .expect("parse trigger registry module"),
842            surface,
843        )
844        .expect("host resource contribution should be linkable");
845    }
846}