Skip to main content

theway_daemon/trigger_engine/execution/
mod.rs

1//! Trigger execution engine (moved out of theway-core into the CLI host).
2//!
3//! [`TriggerExecutor`] is the host-side counterpart of `AgentHarness::handle_trigger`: it
4//! owns the dedup/cycle runtime, the permission hook chain, the audit persistence (via the
5//! core `Session` public API) and the sub-agent execution for accepted triggers. The core
6//! runtime stays state-only — the executor subscribes to core hooks and modifies core
7//! state (session audits, parent transcript promotion) through public APIs, and surfaces
8//! its lifecycle via [`TriggerEvent`](super::event::TriggerEvent) to CLI listeners.
9//!
10//! The executor is created per harness/session by the CLI wiring (`main.rs`), which also
11//! registers transport adapters via [`TriggerExecutor::register_notification_hook`].
12
13pub mod action;
14pub mod promotion;
15pub mod types;
16pub mod utils;
17
18use std::collections::HashMap;
19use std::sync::Arc;
20
21use parking_lot::Mutex;
22use theway_core::Agent;
23use theway_core::agent::session::session::Session;
24use theway_core::types::{AfterToolCallHook, BeforeToolCallHook, StreamFn};
25
26use super::event::{TriggerEvent, TriggerListener};
27use super::notification_hook::{DynNotificationHook, NotificationHookStatus};
28use super::runtime::{EvaluationOutcome, TriggerRuntime, TriggerRuntimeConfig};
29use super::types::{Trigger, TriggerRecord, TriggerState};
30use action::run_trigger_action;
31use utils::{build_trigger_prompt_request, cap_control_plane_audit_label};
32
33// Re-exported to keep the public path `trigger_engine::execution::*` stable. Some test
34// crates compile this tree privately via `#[path]` includes, where rustc flags
35// never-used re-exports — allow it, the shim exists for the API surface.
36#[allow(unused_imports)]
37pub use types::{
38    BeforeTriggerActionContext, BeforeTriggerActionHook, BeforeTriggerContext,
39    BeforeTriggerDecision, BeforeTriggerHook, NotificationStatusSnapshot, OnTriggerPromptHook,
40    PromoteAction, PromotionCondition, PromotionConditionSkipReason, RunningTriggerState,
41    TriggerAction, TriggerDelivery, TriggerPromptDecision, TriggerPromptRequest,
42};
43
44// ─────────────────────────────────────────────────────────────────────────────────────────
45// Trigger executor — the host-side entrypoint (replaces AgentHarness::handle_trigger)
46// ─────────────────────────────────────────────────────────────────────────────────────────
47
48/// Internal record kept under [`TriggerExecutor::running_triggers`]. The public-facing
49/// snapshot is [`RunningTriggerState`]; the cancel token lets [`TriggerExecutor::abort_trigger`]
50/// stop the spawned sub-agent task.
51struct RunningTriggerHandle {
52    state: RunningTriggerState,
53    cancel: tokio_util::sync::CancellationToken,
54}
55
56/// Internal resolution of a `BeforeTriggerDecision::Prompt`. The embedder decision is
57/// resolved through [`OnTriggerPromptHook`]; the audit + `TriggerPromptRequest` event are
58/// written by [`TriggerExecutor::resolve_trigger_prompt`].
59struct ResolvedTriggerPrompt {
60    request: TriggerPromptRequest,
61    decision: TriggerPromptDecision,
62}
63
64/// Host-side trigger pipeline for one harness/session. Constructed by the CLI wiring with
65/// the parent agent + session handles and the same hook closures configured on the
66/// harness; `handle_trigger` replaces the old core entrypoint 1:1.
67pub struct TriggerExecutor {
68    parent_agent: Arc<Agent>,
69    parent_session: Session,
70    /// In-memory dedup + cycle evaluator (moved from the harness).
71    runtime: TriggerRuntime,
72    before_trigger: Option<BeforeTriggerHook>,
73    on_trigger_prompt: Option<OnTriggerPromptHook>,
74    before_trigger_action: Option<BeforeTriggerActionHook>,
75    running_triggers: Arc<Mutex<HashMap<String, RunningTriggerHandle>>>,
76    notification_hooks: Arc<Mutex<Vec<DynNotificationHook>>>,
77    listeners: Arc<Mutex<Vec<TriggerListener>>>,
78    stream_fn: Option<StreamFn>,
79    before_tool_call: Option<BeforeToolCallHook>,
80    after_tool_call: Option<AfterToolCallHook>,
81    active_hook_cancel: Arc<Mutex<Option<tokio_util::sync::CancellationToken>>>,
82}
83
84impl TriggerExecutor {
85    pub fn new(
86        parent_agent: Arc<Agent>,
87        parent_session: Session,
88        runtime: TriggerRuntimeConfig,
89        before_trigger: Option<BeforeTriggerHook>,
90        on_trigger_prompt: Option<OnTriggerPromptHook>,
91        before_trigger_action: Option<BeforeTriggerActionHook>,
92        stream_fn: Option<StreamFn>,
93        before_tool_call: Option<BeforeToolCallHook>,
94        after_tool_call: Option<AfterToolCallHook>,
95    ) -> Self {
96        Self {
97            parent_agent,
98            parent_session,
99            runtime: TriggerRuntime::with_config(runtime),
100            before_trigger,
101            on_trigger_prompt,
102            before_trigger_action,
103            running_triggers: Arc::new(Mutex::new(HashMap::new())),
104            notification_hooks: Arc::new(Mutex::new(Vec::new())),
105            listeners: Arc::new(Mutex::new(Vec::new())),
106            stream_fn,
107            before_tool_call,
108            after_tool_call,
109            active_hook_cancel: Arc::new(Mutex::new(None)),
110        }
111    }
112
113    /// Subscribe to the executor's lifecycle events. Returns an unsubscribe handle.
114    pub fn subscribe(&self, listener: TriggerListener) -> Box<dyn FnOnce() + Send> {
115        self.listeners.lock().push(listener);
116        let listeners = Arc::clone(&self.listeners);
117        let idx = self.listeners.lock().len() - 1;
118        Box::new(move || {
119            listeners.lock().remove(idx);
120        })
121    }
122
123    /// Cancel the in-flight trigger-prompt permission hook (if any). Mirrors the harness
124    /// `abort` semantics for the old core-owned pipeline: the CLI wires Ctrl-C / `/cancel`
125    /// through this alongside `AgentHarness::abort`.
126    pub fn abort(&self) {
127        if let Some(token) = self.active_hook_cancel.lock().as_ref() {
128            token.cancel();
129        }
130    }
131
132    /// Emit a [`TriggerEvent`] to all subscribers, isolating panicking listeners.
133    fn emit(&self, event: TriggerEvent) {
134        let listeners: Vec<TriggerListener> = self.listeners.lock().clone();
135        for listener in listeners {
136            let _ =
137                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| listener(event.clone())));
138        }
139    }
140
141    pub async fn handle_trigger(&self, trigger: Trigger) -> EvaluationOutcome {
142        self.emit(TriggerEvent::TriggerHandlingStart {
143            idempotency_key: trigger.idempotency_key.clone(),
144            source_kind: trigger.source_kind,
145            source_label: trigger.source_label.clone(),
146            event_label: trigger.event_label.clone(),
147            trace_id: trigger.trace_id.clone(),
148        });
149
150        let outcome = self.runtime.evaluate(&trigger);
151
152        let (state, evaluator_decision) = match &outcome {
153            EvaluationOutcome::Accept => {
154                // Evaluator said admit; run the permission hook to decide whether the
155                // accepted trigger advances to `Accepted` or stops at one of the
156                // policy-terminal states (`PermissionDenied` / `NeedsApproval`).
157                let permission_decision = self.run_before_trigger_hook(&trigger).await;
158                match permission_decision {
159                    BeforeTriggerDecision::Allow => (
160                        TriggerState::Accepted,
161                        Some(serde_json::json!({
162                            "outcome": "accept",
163                            "permission": "allow"
164                        })),
165                    ),
166                    BeforeTriggerDecision::Deny { reason } => (
167                        TriggerState::PermissionDenied,
168                        Some(serde_json::json!({
169                            "outcome": "accept",
170                            "permission": "deny",
171                            "reason": reason,
172                        })),
173                    ),
174                    BeforeTriggerDecision::Prompt { reason } => {
175                        let resolved = self.resolve_trigger_prompt(&trigger, reason).await;
176                        let state = match resolved.decision {
177                            TriggerPromptDecision::Allow => TriggerState::Accepted,
178                            TriggerPromptDecision::Deny { .. }
179                            | TriggerPromptDecision::Timeout { .. } => TriggerState::NeedsApproval,
180                        };
181                        (
182                            state,
183                            Some(serde_json::json!({
184                                "outcome": "accept",
185                                "permission": "prompt",
186                                "trigger_prompt_id": resolved.request.trigger_prompt_id,
187                                "prompt_decision": resolved.decision.as_audit_str(),
188                                "reason": resolved.request.reason,
189                                "decision_reason": resolved.decision.reason(),
190                            })),
191                        )
192                    }
193                }
194            }
195            EvaluationOutcome::Deduped {
196                replacement_policy,
197                previous_trace_id,
198            } => (
199                TriggerState::Deduped,
200                Some(serde_json::json!({
201                    "outcome": "deduped",
202                    "replacement_policy": replacement_policy,
203                    "previous_trace_id": previous_trace_id,
204                })),
205            ),
206            EvaluationOutcome::CycleSuppressed { hop_count } => (
207                TriggerState::CycleSuppressed,
208                Some(serde_json::json!({
209                    "outcome": "cycle_suppressed",
210                    "hop_count": hop_count,
211                })),
212            ),
213        };
214
215        let mut record = TriggerRecord::received_from(&trigger);
216        record.state = state;
217        record.evaluator_decision = evaluator_decision.clone();
218
219        let audit_payload = match serde_json::to_value(&record) {
220            Ok(v) => Some(v),
221            Err(e) => {
222                // Audit serialization failure is a programming error (the type derives
223                // Serialize over wholly-owned fields), but we don't want to panic on it
224                // from a user-driven path. Surface as PersistenceError and proceed.
225                self.emit(TriggerEvent::PersistenceError {
226                    context: "trigger_audit".into(),
227                    message: format!("trigger record serialization failed: {e}"),
228                });
229                None
230            }
231        };
232
233        let audit_entry_id = match audit_payload {
234            Some(payload) => match self
235                .parent_session
236                .append_custom(TriggerRecord::CUSTOM_TYPE, Some(payload))
237                .await
238            {
239                Ok(id) => Some(id),
240                Err(e) => {
241                    self.emit(TriggerEvent::PersistenceError {
242                        context: "trigger_audit".into(),
243                        message: format!("trigger audit append failed: {:?}", e.code),
244                    });
245                    None
246                }
247            },
248            None => None,
249        };
250
251        let trace_id = trigger.trace_id.clone();
252        let idempotency_key = trigger.idempotency_key.clone();
253
254        self.emit(TriggerEvent::TriggerHandled {
255            idempotency_key,
256            trace_id: trace_id.clone(),
257            state,
258            audit_entry_id,
259            evaluator_decision,
260        });
261
262        // Sub-agent execution only fires on the policy-Allow Accepted path. Other terminal
263        // states (Deduped / CycleSuppressed / PermissionDenied / NeedsApproval) leave
264        // `handle_trigger` here with only the audit + `TriggerHandled` event written.
265        if state == TriggerState::Accepted {
266            self.spawn_trigger_action(trigger);
267        }
268
269        outcome
270    }
271
272    /// Spawn the detached sub-agent task for an accepted trigger. RFC 1 §5.A: the parent
273    /// `Agent` is single-tenant, so we cannot run the action on the same `AgentHarness`;
274    /// instead each accepted trigger gets its own sub-harness rooted on an in-memory
275    /// session. The parent session only gets the `trigger_result` audit when the sub-agent
276    /// completes (or is cancelled).
277    ///
278    /// **Known limitation in sub-PR 5a**: the sub-agent's session is in-memory and
279    /// discarded when the task finishes. Per the issue #20 amendment, persisted retained
280    /// branches (so `theway --resume <trace_id>` can replay sub-agent transcripts for
281    /// archaeology) is a sub-PR 5c follow-up. `trigger_result.summary` is preserved; the
282    /// full sub-agent transcript is not.
283    fn spawn_trigger_action(&self, trigger: Trigger) {
284        // Snapshot every input the spawned task needs so the closure can be `'static`. We
285        // intentionally do not require `self: &Arc<Self>` to avoid a breaking-change to
286        // existing callers of `AgentHarness::new`; instead we capture the underlying
287        // shared state through individual handles.
288        let trace_id = trigger.trace_id.clone();
289        let source_label = trigger.source_label.clone();
290        let event_label = trigger.event_label.clone();
291        let listeners = Arc::clone(&self.listeners);
292        let parent_session = self.parent_session.clone();
293        let parent_agent = Arc::clone(&self.parent_agent);
294        let running_registry = Arc::clone(&self.running_triggers);
295        let action_hook = self.before_trigger_action.clone();
296        let runtime_snapshot = self.runtime.snapshot();
297        let parent_state = self.parent_agent.state();
298        let parent_model = parent_state.model.clone();
299        let parent_system_prompt = parent_state.system_prompt.clone();
300        let parent_tools = parent_state.tools.clone();
301        let parent_thinking = parent_state.thinking_level;
302        let stream_fn = self.stream_fn.clone();
303        let before_tool_call = self.before_tool_call.clone();
304        let after_tool_call = self.after_tool_call.clone();
305
306        tokio::spawn(async move {
307            run_trigger_action(
308                trigger,
309                trace_id,
310                source_label,
311                event_label,
312                listeners,
313                parent_session,
314                parent_agent,
315                running_registry,
316                action_hook,
317                runtime_snapshot,
318                parent_model,
319                parent_system_prompt,
320                parent_tools,
321                parent_thinking,
322                stream_fn,
323                before_tool_call,
324                after_tool_call,
325            )
326            .await;
327        });
328    }
329
330    /// Invoke the optional permission hook on an accepted trigger. Returns
331    /// [`BeforeTriggerDecision::Allow`] when no hook is configured so the default-allow
332    /// policy is path-equivalent to omitting the hook entirely.
333    ///
334    /// The hook receives a [`CancellationToken`] that the harness does not currently
335    /// cancel; sub-PR 5 will pipe the harness's active-prompt cancel through this token so
336    /// a permission UI can be aborted by Ctrl-C.
337    async fn run_before_trigger_hook(&self, trigger: &Trigger) -> BeforeTriggerDecision {
338        let Some(hook) = self.before_trigger.clone() else {
339            return BeforeTriggerDecision::Allow;
340        };
341        let ctx = BeforeTriggerContext {
342            trigger: trigger.clone(),
343            runtime: self.runtime.snapshot(),
344        };
345        hook(ctx, tokio_util::sync::CancellationToken::new()).await
346    }
347
348    async fn resolve_trigger_prompt(
349        &self,
350        trigger: &Trigger,
351        reason: String,
352    ) -> ResolvedTriggerPrompt {
353        let request = build_trigger_prompt_request(trigger, reason);
354
355        self.emit(TriggerEvent::TriggerPromptRequest {
356            request: request.clone(),
357        });
358
359        let decision = match self.on_trigger_prompt.clone() {
360            Some(hook) => {
361                let cancel = tokio_util::sync::CancellationToken::new();
362                *self.active_hook_cancel.lock() = Some(cancel.clone());
363                let decision = hook(request.clone(), cancel).await;
364                *self.active_hook_cancel.lock() = None;
365                decision
366            }
367            None => TriggerPromptDecision::Deny {
368                reason: Some(
369                    "trigger prompt required but no on_trigger_prompt hook configured \
370                     (fail-closed deny — see issue #110 design v0.2)"
371                        .to_string(),
372                ),
373            },
374        };
375
376        self.write_trigger_prompt_audit(&request, &decision).await;
377        ResolvedTriggerPrompt { request, decision }
378    }
379
380    async fn write_trigger_prompt_audit(
381        &self,
382        request: &TriggerPromptRequest,
383        decision: &TriggerPromptDecision,
384    ) {
385        let data = serde_json::json!({
386            "schema_version": 1,
387            "trigger_prompt_id": request.trigger_prompt_id,
388            "trace_id": request.trace_id,
389            "source_label": cap_control_plane_audit_label(&request.source_label),
390            "receiver_agent_id": request.receiver_agent_id,
391            "sender_agent_id": request.sender_agent_id,
392            "action_class": request.action_class,
393            "decision": decision.as_audit_str(),
394            "reason": decision.reason(),
395            "at": chrono::Utc::now().to_rfc3339(),
396        });
397
398        if let Err(e) = self
399            .parent_session
400            .append_custom("trigger_prompt", Some(data))
401            .await
402        {
403            self.emit(TriggerEvent::PersistenceError {
404                context: "trigger_prompt".into(),
405                message: format!("trigger prompt audit append failed: {:?}", e.code),
406            });
407        }
408    }
409
410    /// Point-in-time view of the harness's notification surface — the
411    /// [`TriggerRuntimeSnapshot`] plus a `Vec<NotificationHookStatus>` collected from each
412    /// registered hook via [`super::notification_hook::NotificationHook::status`]. The hook
413    /// vec is a snapshot, not a live view; new registrations after this call are not
414    /// reflected. Hook impls that have ended naturally still appear here until the next
415    /// registration cycle — consumers should treat `NotificationHookStatus.state` as the
416    /// source of truth for whether a hook is currently live.
417    pub fn notification_status_snapshot(&self) -> NotificationStatusSnapshot {
418        // Clone the `Arc`s out of the registry first so each hook's `status()` runs without
419        // the registry mutex held. A slow `status()` (e.g. one that takes its own internal
420        // lock) would otherwise block concurrent `register_notification_hook` calls.
421        let hook_arcs: Vec<DynNotificationHook> = self.notification_hooks.lock().clone();
422        let hooks: Vec<NotificationHookStatus> = hook_arcs.iter().map(|h| h.status()).collect();
423        // Running triggers: clone the public-facing state out of each handle. Drop the lock
424        // before returning so consumers cannot pin the registry against concurrent inserts /
425        // removes by the spawned sub-agent tasks.
426        let running: Vec<RunningTriggerState> = self
427            .running_triggers
428            .lock()
429            .values()
430            .map(|h| h.state.clone())
431            .collect();
432        NotificationStatusSnapshot {
433            hooks,
434            runtime: self.runtime.snapshot(),
435            running,
436        }
437    }
438
439    /// Cancel the in-flight sub-agent for `trace_id`. No-op if the trigger has already
440    /// completed or was never accepted. The spawned task will observe the cancel inside its
441    /// `select!`, abort the agent loop, and emit `TriggerFailed` with
442    /// `reason == "aborted"` plus a `trigger_result { success: false, summary:
443    /// Some("aborted") }` audit entry.
444    pub fn abort_trigger(&self, trace_id: &str) {
445        if let Some(handle) = self.running_triggers.lock().get(trace_id) {
446            handle.cancel.cancel();
447        }
448    }
449
450    /// Cancel every in-flight sub-agent. Each cancelled task writes its own
451    /// `trigger_result` and emits `TriggerFailed`. Convenience wrapper around
452    /// [`Self::abort_trigger`] for graceful shutdown.
453    pub fn abort_all_triggers(&self) {
454        let cancels: Vec<_> = self
455            .running_triggers
456            .lock()
457            .values()
458            .map(|h| h.cancel.clone())
459            .collect();
460        for c in cancels {
461            c.cancel();
462        }
463    }
464
465    /// Register a [`super::notification_hook::NotificationHook`] with the harness. Spawns
466    /// two detached tokio tasks:
467    /// - **Driver**: calls `hook.run(sink)` and drives the hook's transport (MCP read
468    ///   pump, cron watcher, etc.). Triggers the hook produces flow through
469    ///   the `sink` (an `mpsc::UnboundedSender<Trigger>`).
470    /// - **Pump**: reads from the sink's receiver and calls
471    ///   [`Self::handle_trigger`] for each trigger. Exits naturally when the sender is
472    ///   dropped (e.g. when the hook's `run` future ends).
473    ///
474    /// The hook is stored for [`Self::notification_status_snapshot`] to read. There is no
475    /// unregister API in this PR — hooks live until the harness is dropped or the driver
476    /// task ends; the pump exits naturally when the sender closes. A later sub-PR may add
477    /// explicit shutdown handles if a use case requires them; for now the YAGNI surface is
478    /// "register and forget".
479    ///
480    /// `self: &Arc<Self>` because the pump task needs to clone the harness handle so
481    /// `handle_trigger` is reachable from a `'static` future. Callers already hold the
482    /// harness as `Arc<AgentHarness>` in `crates/harness::main` so this is not a new
483    /// ergonomic ask.
484    pub fn register_notification_hook(self: &Arc<Self>, hook: DynNotificationHook) {
485        use super::notification_hook::TriggerSink;
486        let (sink, mut rx): (TriggerSink, _) = tokio::sync::mpsc::unbounded_channel();
487
488        // Track for status snapshot before spawning so a status read immediately after
489        // returning sees the new hook.
490        self.notification_hooks.lock().push(hook.clone());
491
492        // Driver task: the hook owns transport-side work; we only care about its
493        // completion to free task resources. Errors aren't surfaced to a SessionEvent
494        // here (RFC 1 §4 puts that on the next sub-PR's HookStatusChanged event); the
495        // hook reflects them through its own `status()` call.
496        let hook_driver = hook.clone();
497        tokio::spawn(async move {
498            let _ = hook_driver.run(sink).await;
499        });
500
501        // Pump task: drain triggers into handle_trigger in order. We don't bound the
502        // queue here — the hook's own backpressure is the right place for that since
503        // it knows the transport's per-hook semantics (MCP push has no rate, cron has
504        // burst smoothing, etc.).
505        //
506        // Contract: `handle_trigger` must not panic. The pump deliberately does NOT wrap
507        // the call in `catch_unwind`, because today every transition `handle_trigger` runs
508        // is internal (evaluator + audit append + emit). When sub-PR 4 starts dispatching
509        // accepted triggers into the agent loop (which can panic via user-provided tools /
510        // hooks), this loop will gain a `catch_unwind` shell plus a `HookPumpPanicked`
511        // event so the hook surface can show "pump dead" rather than silently buffering
512        // triggers into a dropped channel.
513        let harness = Arc::clone(self);
514        tokio::spawn(async move {
515            while let Some(trigger) = rx.recv().await {
516                let _ = harness.handle_trigger(trigger).await;
517            }
518        });
519    }
520}
521
522#[cfg(test)]
523mod coverage_gap {
524    use super::*;
525
526    #[tokio::test]
527    async fn abort_without_active_prompt_hook_is_a_noop() {
528        let storage = std::sync::Arc::new(theway_core::MemorySessionStorage::new());
529        let session = Session::new(storage as std::sync::Arc<dyn theway_core::SessionStorage>);
530        let harness = std::sync::Arc::new(theway_core::AgentHarness::new(
531            theway_core::AgentHarnessOptions::new(
532                theway_llm_provider::Model {
533                    id: "faux".into(),
534                    name: "Faux".into(),
535                    api: theway_llm_provider::Api::from("faux"),
536                    provider: theway_llm_provider::Provider::from("faux"),
537                    base_url: String::new(),
538                    reasoning: false,
539                    thinking_level_map: None,
540                    input: vec![],
541                    cost: theway_llm_provider::ModelCost::default(),
542                    context_window: 0,
543                    max_tokens: 0,
544                    headers: None,
545                    compat: None,
546                },
547                session.clone(),
548            ),
549        ));
550        let executor = TriggerExecutor::new(
551            harness.agent_arc(),
552            session,
553            TriggerRuntimeConfig::default(),
554            None,
555            None,
556            None,
557            None,
558            None,
559            None,
560        );
561
562        executor.abort();
563        assert!(executor.notification_status_snapshot().running.is_empty());
564    }
565
566    #[tokio::test]
567    async fn emit_isolates_panicking_listener_and_continues_to_others() {
568        let storage = std::sync::Arc::new(theway_core::MemorySessionStorage::new());
569        let session = Session::new(storage as std::sync::Arc<dyn theway_core::SessionStorage>);
570        let harness = std::sync::Arc::new(theway_core::AgentHarness::new(
571            theway_core::AgentHarnessOptions::new(
572                theway_llm_provider::Model {
573                    id: "faux".into(),
574                    name: "Faux".into(),
575                    api: theway_llm_provider::Api::from("faux"),
576                    provider: theway_llm_provider::Provider::from("faux"),
577                    base_url: String::new(),
578                    reasoning: false,
579                    thinking_level_map: None,
580                    input: vec![],
581                    cost: theway_llm_provider::ModelCost::default(),
582                    context_window: 0,
583                    max_tokens: 0,
584                    headers: None,
585                    compat: None,
586                },
587                session.clone(),
588            ),
589        ));
590        let executor = TriggerExecutor::new(
591            harness.agent_arc(),
592            session,
593            TriggerRuntimeConfig::default(),
594            None,
595            None,
596            None,
597            None,
598            None,
599            None,
600        );
601
602        let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
603        let _panic_listener = executor.subscribe(std::sync::Arc::new(move |_| {
604            panic!("listener panic");
605        }));
606        let calls_sink2 = calls.clone();
607        let _counting_listener = executor.subscribe(std::sync::Arc::new(move |_| {
608            calls_sink2.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
609        }));
610
611        executor.emit(TriggerEvent::TriggerHandlingStart {
612            idempotency_key: "k".into(),
613            source_kind: super::super::types::SourceKind::Mcp,
614            source_label: "src".into(),
615            event_label: "evt".into(),
616            trace_id: "trace".into(),
617        });
618
619        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
620    }
621
622    #[tokio::test]
623    async fn abort_trigger_unknown_and_all_are_noops_when_no_running_triggers() {
624        let storage = std::sync::Arc::new(theway_core::MemorySessionStorage::new());
625        let session = Session::new(storage as std::sync::Arc<dyn theway_core::SessionStorage>);
626        let harness = std::sync::Arc::new(theway_core::AgentHarness::new(
627            theway_core::AgentHarnessOptions::new(
628                theway_llm_provider::Model {
629                    id: "faux".into(),
630                    name: "Faux".into(),
631                    api: theway_llm_provider::Api::from("faux"),
632                    provider: theway_llm_provider::Provider::from("faux"),
633                    base_url: String::new(),
634                    reasoning: false,
635                    thinking_level_map: None,
636                    input: vec![],
637                    cost: theway_llm_provider::ModelCost::default(),
638                    context_window: 0,
639                    max_tokens: 0,
640                    headers: None,
641                    compat: None,
642                },
643                session.clone(),
644            ),
645        ));
646        let executor = TriggerExecutor::new(
647            harness.agent_arc(),
648            session,
649            TriggerRuntimeConfig::default(),
650            None,
651            None,
652            None,
653            None,
654            None,
655            None,
656        );
657
658        executor.abort_trigger("no-such-trace");
659        executor.abort_all_triggers();
660        assert!(executor.notification_status_snapshot().running.is_empty());
661    }
662}