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}