1use serde_json::json;
2
3use super::execution_context::RuntimeExecutionContext;
4use super::tool_execution::ToolInvocationReply;
5use crate::tool_dispatch::ToolPreparationOutcome;
6use crate::{
7 ProcessHandleDescriptor, ProcessInput, ProcessRegistration, ToolCallOutput, ToolCallRecord,
8};
9
10const PROCESS_HANDLE_KIND: &str = "process";
11
12impl RuntimeExecutionContext<'_> {
13 pub(super) fn process_handle_value(id: &str, tool_name: &str) -> serde_json::Value {
14 let _ = tool_name;
15 Self::process_handle_json(id)
16 }
17
18 pub fn process_handle_json(id: &str) -> serde_json::Value {
19 json!({
20 "__handle__": "process",
21 "id": id,
22 })
23 }
24
25 pub(super) fn process_status_value(status: &crate::ProcessRecord) -> serde_json::Value {
26 json!({
27 "process_id": status.id,
28 "status": status.status.label(),
29 })
30 }
31
32 pub(super) fn parse_process_handle(
33 handle: &serde_json::Value,
34 ) -> Result<(String, Option<String>), String> {
35 let kind = handle
36 .get("__handle__")
37 .and_then(|value| value.as_str())
38 .ok_or_else(|| "Invalid process handle: missing `__handle__`".to_string())?;
39 if kind != PROCESS_HANDLE_KIND {
40 return Err(format!("Invalid process handle kind: {kind}"));
41 }
42 let id = handle
43 .get("id")
44 .and_then(|value| value.as_str())
45 .filter(|value| !value.is_empty())
46 .ok_or_else(|| "Invalid process handle: missing `id`".to_string())?;
47 let tool_name = handle
48 .get("tool")
49 .and_then(|value| value.as_str())
50 .map(str::to_string);
51 Ok((id.to_string(), tool_name))
52 }
53
54 pub(super) async fn start_tool_process(
55 &self,
56 call_id: String,
57 tool_name: String,
58 args: serde_json::Value,
59 ) -> ToolInvocationReply {
60 let handle_id = call_id.clone();
61 let pending_call = crate::sansio::PendingToolCall {
62 call_id: call_id.clone(),
63 tool_name: tool_name.clone(),
64 args: args.clone(),
65 replay: None,
66 };
67 let prepared_call = match self.prepare_tool_call(pending_call).await {
68 ToolPreparationOutcome::Prepared(prepared) => prepared,
69 ToolPreparationOutcome::Completed(outcome) => {
70 let mut record = outcome.record;
71 record.call_id = Some(call_id);
72 return ToolInvocationReply::from_output(record.output.clone()).with_record(record);
73 }
74 };
75 let registration = ProcessRegistration::session_start_draft(
76 handle_id.clone(),
77 ProcessInput::ToolCall {
78 call: prepared_call.clone(),
79 },
80 crate::RecoveryDisposition::Rerunnable,
83 );
84 let registration = match self
85 .attach_captured_process_execution_env(registration)
86 .await
87 {
88 Ok(registration) => registration,
89 Err(err) => return ToolInvocationReply::error(json!(err.to_string())),
90 };
91 if let Err(err) =
92 self.dispatch
93 .processes
94 .start(
95 &self.session_id,
96 registration,
97 crate::ProcessStartOptions::new().with_descriptor(
98 ProcessHandleDescriptor::new(Some("tool"), Some(tool_name.clone())),
99 ),
100 self.process_scope(self.parent_invocation.clone()),
101 )
102 .await
103 {
104 return ToolInvocationReply::error(json!(err.to_string()));
105 }
106
107 let handle_value = Self::process_handle_value(&handle_id, &tool_name);
108 let record = ToolCallRecord {
109 call_id: Some(call_id),
110 tool: prepared_call.tool_name,
111 args: prepared_call.args,
112 output: ToolCallOutput::success(handle_value.clone()),
113 duration_ms: 0,
114 };
115 ToolInvocationReply::success(handle_value).with_record(record)
116 }
117
118 fn elapsed_ms(&self, started: std::time::Instant) -> u64 {
119 self.dispatch
120 .clock
121 .now()
122 .duration_since(started)
123 .as_millis() as u64
124 }
125
126 fn recorded_process_reply(
127 call_id: String,
128 tool: impl Into<String>,
129 args: serde_json::Value,
130 output: ToolCallOutput,
131 duration_ms: u64,
132 ) -> ToolInvocationReply {
133 let record = ToolCallRecord {
134 call_id: Some(call_id),
135 tool: tool.into(),
136 args,
137 output: output.clone(),
138 duration_ms,
139 };
140 ToolInvocationReply::from_output(output).with_record(record)
141 }
142
143 fn recorded_process_error(
144 call_id: String,
145 tool: &'static str,
146 args: serde_json::Value,
147 message: impl Into<String>,
148 duration_ms: u64,
149 ) -> ToolInvocationReply {
150 let output = ToolInvocationReply::error(json!(message.into())).output;
151 Self::recorded_process_reply(call_id, tool, args, output, duration_ms)
152 }
153
154 pub(super) async fn await_process_handle(
155 &self,
156 call_id: String,
157 handle: serde_json::Value,
158 ) -> ToolInvocationReply {
159 let started = self.dispatch.clock.now();
160 let args = json!({ "handle": handle.clone() });
161 let (handle_id, _hinted_tool_name) = match Self::parse_process_handle(&handle) {
162 Ok(parsed) => parsed,
163 Err(err) => {
164 return Self::recorded_process_error(
165 call_id,
166 "await_process",
167 args,
168 err,
169 self.elapsed_ms(started),
170 );
171 }
172 };
173 if !self.is_run_local_process(&handle_id)
177 && let Err(err) = self
178 .dispatch
179 .processes
180 .validate_visible(
181 &self.session_id,
182 std::slice::from_ref(&handle_id),
183 self.process_scope(self.parent_invocation.clone()),
184 )
185 .await
186 {
187 return Self::recorded_process_error(
188 call_id,
189 "await_process",
190 args,
191 err.to_string(),
192 self.elapsed_ms(started),
193 );
194 }
195 let output = self
196 .await_process_with_cancellation(
197 &handle_id,
198 self.parent_invocation.clone(),
199 self.cancellation_token.clone(),
200 )
201 .await;
202 let output = match output {
203 Ok(output) => output.into_tool_output(),
204 Err(err) => ToolInvocationReply::error(json!(err.to_string())).output,
205 };
206 Self::recorded_process_reply(
207 call_id,
208 "await_process",
209 args,
210 output,
211 self.elapsed_ms(started),
212 )
213 }
214
215 pub(super) async fn signal_process_handle(
216 &self,
217 call_id: String,
218 handle: serde_json::Value,
219 signal_name: String,
220 payload: serde_json::Value,
221 ) -> ToolInvocationReply {
222 let started = self.dispatch.clock.now();
223 let args = json!({
224 "handle": handle.clone(),
225 "signal_name": signal_name.clone(),
226 "payload": payload.clone()
227 });
228 let (handle_id, _hinted_tool_name) = match Self::parse_process_handle(&handle) {
229 Ok(parsed) => parsed,
230 Err(err) => {
231 return Self::recorded_process_error(
232 call_id,
233 "signal_process",
234 args,
235 err,
236 self.elapsed_ms(started),
237 );
238 }
239 };
240 let signal_id = format!("process-{call_id}");
241 let output = match self
242 .dispatch
243 .processes
244 .signal(
245 &self.session_id,
246 &handle_id,
247 signal_name,
248 signal_id,
249 payload,
250 self.process_scope(self.parent_invocation.clone()),
251 )
252 .await
253 {
254 Ok(event) => ToolCallOutput::success(json!({
255 "process_id": event.process_id,
256 "sequence": event.sequence,
257 })),
258 Err(err) => ToolInvocationReply::error(json!(format!("signal failed: {err}"))).output,
259 };
260 Self::recorded_process_reply(
261 call_id,
262 "signal_process",
263 args,
264 output,
265 self.elapsed_ms(started),
266 )
267 }
268
269 pub(super) async fn cancel_process_handle(
270 &self,
271 call_id: String,
272 handle: serde_json::Value,
273 ) -> ToolInvocationReply {
274 let started = self.dispatch.clock.now();
275 let args = json!({ "handle": handle.clone() });
276 let (handle_id, _hinted_tool_name) = match Self::parse_process_handle(&handle) {
277 Ok(parsed) => parsed,
278 Err(err) => {
279 return Self::recorded_process_error(
280 call_id,
281 "cancel_process",
282 args,
283 err,
284 self.elapsed_ms(started),
285 );
286 }
287 };
288 let result = if self.is_run_local_process(&handle_id) {
292 self.dispatch
293 .processes
294 .cancel(
295 &self.session_id,
296 &handle_id,
297 self.process_scope(self.parent_invocation.clone()),
298 )
299 .await
300 } else {
301 self.dispatch
302 .process_cancel_ability
303 .cancel(
304 self.dispatch.processes.as_ref(),
305 crate::ProcessCancelRequest::new(
306 &self.session_id,
307 &handle_id,
308 self.process_scope(self.parent_invocation.clone()),
309 crate::ProcessCancelSource::Process,
310 )
311 .with_handle(handle)
312 .with_reason("requested by process handle"),
313 )
314 .await
315 };
316 let output = match result {
317 Ok(status) => ToolCallOutput::success(Self::process_status_value(&status)),
318 Err(err) => ToolInvocationReply::error(json!(format!("cancel failed: {err}"))).output,
319 };
320 Self::recorded_process_reply(
321 call_id,
322 "cancel_process",
323 args,
324 output,
325 self.elapsed_ms(started),
326 )
327 }
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333 use crate::plugin::PluginHost;
334 use crate::runtime::RuntimeEffectControllerHandle;
335 use crate::tool_dispatch::ToolDispatchContext;
336 use crate::{
337 PreparedToolCall, ProcessRegistry, ToolCall, ToolDefinition, ToolPrepareCall, ToolProvider,
338 ToolResult,
339 };
340 use std::collections::BTreeMap;
341 use std::sync::Arc;
342 use std::sync::Mutex;
343 use std::sync::atomic::{AtomicUsize, Ordering};
344
345 struct PrepareRecordingTool {
346 prepares: Arc<AtomicUsize>,
347 }
348
349 #[derive(Default)]
350 struct DenyCancelAbility {
351 calls: Mutex<Vec<(crate::ProcessCancelSource, String)>>,
352 }
353
354 impl DenyCancelAbility {
355 fn calls(&self) -> Vec<(crate::ProcessCancelSource, String)> {
356 self.calls.lock().expect("cancel calls").clone()
357 }
358 }
359
360 #[async_trait::async_trait]
361 impl crate::ProcessCancelAbility for DenyCancelAbility {
362 async fn cancel(
363 &self,
364 _processes: &dyn crate::ProcessService,
365 request: crate::ProcessCancelRequest<'_>,
366 ) -> Result<crate::ProcessRecord, crate::PluginError> {
367 self.calls
368 .lock()
369 .expect("cancel calls")
370 .push((request.source, request.process_id.to_string()));
371 Err(crate::PluginError::Session("denied by host".to_string()))
372 }
373 }
374
375 fn process_tool_definition() -> ToolDefinition {
376 ToolDefinition::raw(
377 "tool:process_prepare",
378 "process_prepare",
379 "Records preparation before background registration.",
380 serde_json::json!({
381 "type": "object",
382 "properties": {
383 "input": { "type": "string" }
384 },
385 "additionalProperties": false
386 }),
387 serde_json::json!({ "type": "object", "additionalProperties": true }),
388 )
389 }
390
391 #[async_trait::async_trait]
392 impl ToolProvider for PrepareRecordingTool {
393 fn tool_manifests(&self) -> Vec<crate::ToolManifest> {
394 vec![process_tool_definition().manifest()]
395 }
396
397 fn resolve_contract(&self, name: &str) -> Option<Arc<crate::ToolContract>> {
398 (name == "process_prepare").then(|| Arc::new(process_tool_definition().contract()))
399 }
400
401 async fn prepare_tool_call(
402 &self,
403 call: ToolPrepareCall<'_>,
404 ) -> Result<PreparedToolCall, ToolResult> {
405 self.prepares.fetch_add(1, Ordering::SeqCst);
406 Ok(PreparedToolCall::from_parts(
407 call.pending.call_id,
408 call.tool_id,
409 call.pending.tool_name,
410 call.pending.args,
411 call.pending.replay,
412 serde_json::json!({ "prepared": true }),
413 ))
414 }
415
416 async fn execute(&self, call: ToolCall<'_>) -> ToolResult {
417 ToolResult::ok(serde_json::json!({
418 "payload": call.context.prepared_payload().clone(),
419 }))
420 }
421 }
422
423 #[tokio::test]
424 async fn process_handle_start_registers_prepared_tool_call() {
425 let prepares = Arc::new(AtomicUsize::new(0));
426 let provider: Arc<dyn ToolProvider> = Arc::new(PrepareRecordingTool {
427 prepares: Arc::clone(&prepares),
428 });
429 let plugins = PluginHost::empty()
430 .build_session("root", None)
431 .expect("plugin session");
432 let tools = Arc::clone(&provider);
433 let tool_catalog = Arc::new(crate::ToolCatalog::from_tools(
434 provider.tool_manifests(),
435 BTreeMap::new(),
436 ));
437 let host = Arc::new(crate::testing::MockSessionManager::default());
438 let (event_tx, _event_rx) = tokio::sync::mpsc::channel(8);
439 let dispatch = Arc::new(ToolDispatchContext {
440 plugins,
441 tools,
442 tool_catalog,
443 sessions: host.clone(),
444 session_lifecycle: host.clone(),
445 session_graph: host.clone(),
446 processes: host.clone(),
447 process_cancel_ability: Arc::new(crate::DefaultProcessCancelAbility),
448 trigger_router: None,
449 effect_controller: RuntimeEffectControllerHandle::shared(Arc::new(
450 crate::InlineRuntimeEffectController::default(),
451 )),
452 direct_completions: crate::DirectCompletionClient::unavailable(
453 "direct completions are unavailable in this test context",
454 ),
455 parent_invocation: None,
456 execution_env_spec: crate::ProcessExecutionEnvSpec::new(
457 crate::PluginOptions::default(),
458 crate::SessionPolicy::default(),
459 ),
460 session_id: "session".to_string(),
461 agent_frame_id: String::new(),
462 event_tx,
463 checkpoint_messages: crate::tool_dispatch::CheckpointMessageBuffer::default(),
464 trigger_outcomes: crate::tool_dispatch::ToolTriggerOutcomeBuffer::default(),
465 attachment_store: Arc::new(crate::SessionAttachmentStore::in_memory()),
466 attachment_source_policy: Arc::new(crate::OpenAttachmentSourcePolicy),
467 turn_context: crate::TurnContext::default(),
468 clock: std::sync::Arc::new(crate::SystemClock),
469 });
470 let context = RuntimeExecutionContext::new(
471 "session".to_string(),
472 dispatch,
473 Arc::new(crate::InMemoryProcessExecutionEnvStore::new()),
474 Arc::new(crate::SessionAttachmentStore::in_memory()),
475 Arc::new(crate::ChronologicalProjection::default()),
476 None,
477 crate::TurnContext::default(),
478 )
479 .with_execution_env_spec(crate::ProcessExecutionEnvSpec::new(
480 crate::PluginOptions::default(),
481 crate::runtime::tests::helpers::standard_test_policy(),
482 ));
483
484 let started = context
485 .start_tool_process(
486 "async-call-1".to_string(),
487 "process_prepare".to_string(),
488 serde_json::json!({ "input": "live" }),
489 )
490 .await;
491 let crate::ToolCallOutcome::Success(handle) = started.output.outcome else {
492 panic!("expected process handle output");
493 };
494 assert_eq!(
495 handle
496 .to_json_value()
497 .get("id")
498 .and_then(|value| value.as_str()),
499 Some("async-call-1")
500 );
501 assert_eq!(prepares.load(Ordering::SeqCst), 1);
502 let record = host
503 .process_registry
504 .get_process("async-call-1")
505 .await
506 .expect("registered process");
507 let ProcessInput::ToolCall { call } = record.input.as_ref() else {
508 panic!("expected prepared tool call process input");
509 };
510 assert_eq!(call.tool_name, "process_prepare");
511 assert_eq!(call.args, serde_json::json!({ "input": "live" }));
512 assert_eq!(
513 call.prepared_payload,
514 serde_json::json!({ "prepared": true })
515 );
516
517 let awaited = context
518 .await_process_handle("await-async-call-1".to_string(), handle.to_json_value())
519 .await;
520
521 assert!(awaited.output.is_success());
522 let record = awaited.record.expect("await record");
523 assert_eq!(record.call_id.as_deref(), Some("await-async-call-1"));
524 assert_eq!(record.tool, "await_process");
525 }
526
527 #[tokio::test]
528 async fn process_handle_signal_appends_event_from_foreground() {
529 let provider: Arc<dyn ToolProvider> = Arc::new(PrepareRecordingTool {
530 prepares: Arc::new(AtomicUsize::new(0)),
531 });
532 let plugins = PluginHost::empty()
533 .build_session("root", None)
534 .expect("plugin session");
535 let tool_catalog = Arc::new(crate::ToolCatalog::from_tools(
536 provider.tool_manifests(),
537 BTreeMap::new(),
538 ));
539 let host = Arc::new(crate::testing::MockSessionManager::default());
540 host.process_registry
541 .register_process(
542 ProcessRegistration::new(
543 "target-process",
544 ProcessInput::External {
545 metadata: serde_json::Value::Null,
546 },
547 crate::RecoveryDisposition::ExternallyOwned,
548 crate::ProcessProvenance::host(),
549 )
550 .with_extra_event_types([crate::ProcessEventType {
551 name: "signal.ready".to_string(),
552 payload_schema: crate::LashSchema::any(),
553 semantics: crate::ProcessEventSemanticsSpec::default(),
554 }]),
555 )
556 .await
557 .expect("register target process");
558 let (event_tx, _event_rx) = tokio::sync::mpsc::channel(8);
559 let dispatch = Arc::new(ToolDispatchContext {
560 plugins,
561 tools: provider,
562 tool_catalog,
563 sessions: host.clone(),
564 session_lifecycle: host.clone(),
565 session_graph: host.clone(),
566 processes: host.clone(),
567 process_cancel_ability: Arc::new(crate::DefaultProcessCancelAbility),
568 trigger_router: None,
569 effect_controller: RuntimeEffectControllerHandle::shared(Arc::new(
570 crate::InlineRuntimeEffectController::default(),
571 )),
572 direct_completions: crate::DirectCompletionClient::unavailable(
573 "direct completions are unavailable in this test context",
574 ),
575 parent_invocation: None,
576 execution_env_spec: crate::ProcessExecutionEnvSpec::new(
577 crate::PluginOptions::default(),
578 crate::SessionPolicy::default(),
579 ),
580 session_id: "session".to_string(),
581 agent_frame_id: String::new(),
582 event_tx,
583 checkpoint_messages: crate::tool_dispatch::CheckpointMessageBuffer::default(),
584 trigger_outcomes: crate::tool_dispatch::ToolTriggerOutcomeBuffer::default(),
585 attachment_store: Arc::new(crate::SessionAttachmentStore::in_memory()),
586 attachment_source_policy: Arc::new(crate::OpenAttachmentSourcePolicy),
587 turn_context: crate::TurnContext::default(),
588 clock: std::sync::Arc::new(crate::SystemClock),
589 });
590 let context = RuntimeExecutionContext::new(
591 "session".to_string(),
592 dispatch,
593 Arc::new(crate::InMemoryProcessExecutionEnvStore::new()),
594 Arc::new(crate::SessionAttachmentStore::in_memory()),
595 Arc::new(crate::ChronologicalProjection::default()),
596 None,
597 crate::TurnContext::default(),
598 );
599
600 let handle = json!({ "__handle__": "process", "id": "target-process" });
601 let signalled = context
602 .signal_process_handle(
603 "signal-1".to_string(),
604 handle,
605 "ready".to_string(),
606 json!({ "kind": "ping" }),
607 )
608 .await;
609
610 assert!(
611 signalled.output.is_success(),
612 "{:?}",
613 signalled.output.value_for_projection()
614 );
615 let record = signalled.record.expect("signal record");
616 assert_eq!(record.call_id.as_deref(), Some("signal-1"));
617 assert_eq!(record.tool, "signal_process");
618 let events = host
619 .process_registry
620 .events_after("target-process", 0)
621 .await
622 .expect("list events");
623 assert!(
624 events.iter().any(|event| event.event_type == "signal.ready"
625 && event.payload.get("kind") == Some(&json!("ping"))),
626 "expected appended signal.ready event, got {events:?}"
627 );
628 }
629
630 #[tokio::test]
631 async fn process_handle_await_and_cancel_require_session_grant() {
632 let provider: Arc<dyn ToolProvider> = Arc::new(PrepareRecordingTool {
633 prepares: Arc::new(AtomicUsize::new(0)),
634 });
635 let plugins = PluginHost::empty()
636 .build_session("root", None)
637 .expect("plugin session");
638 let tool_catalog = Arc::new(crate::ToolCatalog::from_tools(
639 provider.tool_manifests(),
640 BTreeMap::new(),
641 ));
642 let host = Arc::new(crate::testing::MockSessionManager::default());
643 host.process_registry
644 .register_process(ProcessRegistration::new(
645 "hidden-process",
646 ProcessInput::External {
647 metadata: serde_json::Value::Null,
648 },
649 crate::RecoveryDisposition::ExternallyOwned,
650 crate::ProcessProvenance::host(),
651 ))
652 .await
653 .expect("register hidden process");
654 let (event_tx, _event_rx) = tokio::sync::mpsc::channel(8);
655 let dispatch = Arc::new(ToolDispatchContext {
656 plugins,
657 tools: provider,
658 tool_catalog,
659 sessions: host.clone(),
660 session_lifecycle: host.clone(),
661 session_graph: host.clone(),
662 processes: host.clone(),
663 process_cancel_ability: Arc::new(crate::DefaultProcessCancelAbility),
664 trigger_router: None,
665 effect_controller: RuntimeEffectControllerHandle::shared(Arc::new(
666 crate::InlineRuntimeEffectController::default(),
667 )),
668 direct_completions: crate::DirectCompletionClient::unavailable(
669 "direct completions are unavailable in this test context",
670 ),
671 parent_invocation: None,
672 execution_env_spec: crate::ProcessExecutionEnvSpec::new(
673 crate::PluginOptions::default(),
674 crate::SessionPolicy::default(),
675 ),
676 session_id: "session".to_string(),
677 agent_frame_id: String::new(),
678 event_tx,
679 checkpoint_messages: crate::tool_dispatch::CheckpointMessageBuffer::default(),
680 trigger_outcomes: crate::tool_dispatch::ToolTriggerOutcomeBuffer::default(),
681 attachment_store: Arc::new(crate::SessionAttachmentStore::in_memory()),
682 attachment_source_policy: Arc::new(crate::OpenAttachmentSourcePolicy),
683 turn_context: crate::TurnContext::default(),
684 clock: std::sync::Arc::new(crate::SystemClock),
685 });
686 let context = RuntimeExecutionContext::new(
687 "session".to_string(),
688 dispatch,
689 Arc::new(crate::InMemoryProcessExecutionEnvStore::new()),
690 Arc::new(crate::SessionAttachmentStore::in_memory()),
691 Arc::new(crate::ChronologicalProjection::default()),
692 None,
693 crate::TurnContext::default(),
694 );
695 let handle = json!({
696 "__handle__": "process",
697 "id": "hidden-process"
698 });
699
700 let awaited = context
701 .await_process_handle("await-hidden-process".to_string(), handle.clone())
702 .await;
703 let cancelled = context
704 .cancel_process_handle("cancel-hidden-process".to_string(), handle)
705 .await;
706
707 assert!(!awaited.output.is_success());
708 assert!(!cancelled.output.is_success());
709 assert_eq!(
710 awaited
711 .record
712 .as_ref()
713 .and_then(|record| record.call_id.as_deref()),
714 Some("await-hidden-process")
715 );
716 assert_eq!(
717 cancelled
718 .record
719 .as_ref()
720 .and_then(|record| record.call_id.as_deref()),
721 Some("cancel-hidden-process")
722 );
723 }
724
725 #[tokio::test]
726 async fn process_handle_cancel_uses_host_cancel_ability() {
727 let provider: Arc<dyn ToolProvider> = Arc::new(PrepareRecordingTool {
728 prepares: Arc::new(AtomicUsize::new(0)),
729 });
730 let plugins = PluginHost::empty()
731 .build_session("root", None)
732 .expect("plugin session");
733 let tool_catalog = Arc::new(crate::ToolCatalog::from_tools(
734 provider.tool_manifests(),
735 BTreeMap::new(),
736 ));
737 let host = Arc::new(crate::testing::MockSessionManager::default());
738 let ability = Arc::new(DenyCancelAbility::default());
739 let (event_tx, _event_rx) = tokio::sync::mpsc::channel(8);
740 let dispatch = Arc::new(ToolDispatchContext {
741 plugins,
742 tools: provider,
743 tool_catalog,
744 sessions: host.clone(),
745 session_lifecycle: host.clone(),
746 session_graph: host,
747 processes: Arc::new(crate::UnavailableProcessService),
748 process_cancel_ability: ability.clone(),
749 trigger_router: None,
750 effect_controller: RuntimeEffectControllerHandle::shared(Arc::new(
751 crate::InlineRuntimeEffectController::default(),
752 )),
753 direct_completions: crate::DirectCompletionClient::unavailable(
754 "direct completions are unavailable in this test context",
755 ),
756 parent_invocation: None,
757 execution_env_spec: crate::ProcessExecutionEnvSpec::new(
758 crate::PluginOptions::default(),
759 crate::SessionPolicy::default(),
760 ),
761 session_id: "session".to_string(),
762 agent_frame_id: String::new(),
763 event_tx,
764 checkpoint_messages: crate::tool_dispatch::CheckpointMessageBuffer::default(),
765 trigger_outcomes: crate::tool_dispatch::ToolTriggerOutcomeBuffer::default(),
766 attachment_store: Arc::new(crate::SessionAttachmentStore::in_memory()),
767 attachment_source_policy: Arc::new(crate::OpenAttachmentSourcePolicy),
768 turn_context: crate::TurnContext::default(),
769 clock: std::sync::Arc::new(crate::SystemClock),
770 });
771 let context = RuntimeExecutionContext::new(
772 "session".to_string(),
773 dispatch,
774 Arc::new(crate::InMemoryProcessExecutionEnvStore::new()),
775 Arc::new(crate::SessionAttachmentStore::in_memory()),
776 Arc::new(crate::ChronologicalProjection::default()),
777 None,
778 crate::TurnContext::default(),
779 );
780
781 let cancelled = context
782 .cancel_process_handle(
783 "cancel-process-1".to_string(),
784 json!({
785 "__handle__": "process",
786 "id": "process-1"
787 }),
788 )
789 .await;
790
791 assert!(!cancelled.output.is_success());
792 assert_eq!(
793 cancelled.output.value_for_projection()["message"],
794 json!("cancel failed: plugin session error: denied by host")
795 );
796 assert_eq!(
797 ability.calls(),
798 vec![(crate::ProcessCancelSource::Process, "process-1".to_string())]
799 );
800 }
801}