Skip to main content

orcs_runtime/components/
echo_with_hil.rs

1//! Echo Component with HIL (Human-in-the-Loop) Support.
2//!
3//! A component that demonstrates EventBus-based HIL integration.
4//! Before echoing user input, it requests Human approval via Signal.
5//!
6//! # Architecture
7//!
8//! This component is decoupled from HilComponent. It:
9//! - Subscribes to `EventCategory::Echo`
10//! - Manages its own pending approval state
11//! - Receives Approve/Reject signals directly
12//!
13//! # Flow
14//!
15//! ```text
16//! User                 EchoWithHilComponent              Human
17//!   │                         │                            │
18//!   │ Request: "echo Hi"      │                            │
19//!   ├────────────────────────►│                            │
20//!   │                         │ Response: pending          │
21//!   │◄────────────────────────┤ (approval_id)              │
22//!   │                         │                            │
23//!   │                         │ (waiting for approval)     │
24//!   │                         │                            │
25//!   │ Signal::Approve(id)     │                            │
26//!   ├────────────────────────►│                            │
27//!   │                         │ Execute echo               │
28//!   │       "Echo: Hi"        │                            │
29//!   │◄────────────────────────┤                            │
30//! ```
31
32use orcs_component::{
33    Component, ComponentError, Emitter, EventCategory, Package, PackageError, PackageInfo,
34    Packageable, Status,
35};
36use orcs_event::{Request, Signal, SignalKind, SignalResponse};
37use orcs_types::ComponentId;
38use serde::{Deserialize, Serialize};
39use serde_json::Value;
40
41/// Decorator package configuration.
42///
43/// Modifies echo output with prefix and suffix.
44#[derive(Debug, Clone, Serialize, Deserialize, Default)]
45pub struct DecoratorConfig {
46    /// Prefix to add before the echo message.
47    pub prefix: String,
48    /// Suffix to add after the echo message.
49    pub suffix: String,
50}
51
52/// State of an echo request awaiting approval.
53#[derive(Debug, Clone)]
54struct PendingEcho {
55    /// The approval request ID.
56    approval_id: String,
57    /// The original message to echo.
58    message: String,
59    /// Human-readable description of the approval request.
60    description: String,
61}
62
63/// Echo component with HIL integration.
64///
65/// Requests Human approval before echoing messages.
66/// Subscribes to `EventCategory::Echo` for request routing.
67///
68/// Supports packages for customizing output (e.g., decorators).
69pub struct EchoWithHilComponent {
70    id: ComponentId,
71    status: Status,
72    /// Pending echo request awaiting approval.
73    pending: Option<PendingEcho>,
74    /// Last echo result (for testing/inspection).
75    last_result: Option<String>,
76    /// Installed packages.
77    installed_packages: Vec<PackageInfo>,
78    /// Current decorator configuration.
79    decorator: DecoratorConfig,
80    /// Emitter for outputting results to ClientRunner.
81    emitter: Option<Box<dyn Emitter>>,
82}
83
84impl EchoWithHilComponent {
85    /// Creates a new EchoWithHilComponent.
86    #[must_use]
87    pub fn new() -> Self {
88        Self {
89            id: ComponentId::builtin("echo_with_hil"),
90            status: Status::Idle,
91            pending: None,
92            last_result: None,
93            installed_packages: Vec::new(),
94            decorator: DecoratorConfig::default(),
95            emitter: None,
96        }
97    }
98
99    /// Returns the last echo result.
100    #[must_use]
101    pub fn last_result(&self) -> Option<&str> {
102        self.last_result.as_deref()
103    }
104
105    /// Returns whether there's a pending approval.
106    #[must_use]
107    pub fn has_pending(&self) -> bool {
108        self.pending.is_some()
109    }
110
111    /// Returns the pending approval ID if any.
112    #[must_use]
113    pub fn pending_approval_id(&self) -> Option<&str> {
114        self.pending.as_ref().map(|p| p.approval_id.as_str())
115    }
116
117    /// Returns the pending approval description if any.
118    #[must_use]
119    pub fn pending_description(&self) -> Option<&str> {
120        self.pending.as_ref().map(|p| p.description.as_str())
121    }
122
123    /// Submits an echo request for approval.
124    ///
125    /// Uses provided approval_id if given, otherwise generates a new one.
126    fn submit_for_approval(&mut self, message: String, provided_id: Option<String>) -> String {
127        let approval_id = provided_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
128        let description = format!("You say '{}'?", message);
129
130        self.pending = Some(PendingEcho {
131            approval_id: approval_id.clone(),
132            message,
133            description,
134        });
135        self.status = Status::Running;
136
137        approval_id
138    }
139
140    /// Executes the echo after approval.
141    ///
142    /// Applies decorator prefix/suffix if configured.
143    fn execute_echo(&mut self, message: &str) -> String {
144        let decorated = format!(
145            "{}{}{}",
146            self.decorator.prefix, message, self.decorator.suffix
147        );
148        let result = format!("Echo: {}", decorated);
149        self.last_result = Some(result.clone());
150        self.status = Status::Idle;
151        result
152    }
153
154    /// Returns the current decorator configuration.
155    #[must_use]
156    pub fn decorator(&self) -> &DecoratorConfig {
157        &self.decorator
158    }
159
160    /// Handles an Approve signal.
161    fn handle_approve(&mut self, approval_id: &str) -> bool {
162        let Some(pending) = &self.pending else {
163            return false;
164        };
165
166        if pending.approval_id != approval_id {
167            return false;
168        }
169
170        let message = pending.message.clone();
171        self.pending = None;
172        let result = self.execute_echo(&message);
173
174        // Send result to ClientRunner via Event
175        self.send_result(&result);
176        true
177    }
178
179    /// Sends the echo result via the emitter.
180    fn send_result(&self, result: &str) {
181        if let Some(emitter) = &self.emitter {
182            emitter.emit_output(result);
183        }
184    }
185
186    /// Handles a Reject signal.
187    fn handle_reject(&mut self, approval_id: &str, reason: Option<&str>) -> bool {
188        let Some(pending) = &self.pending else {
189            return false;
190        };
191
192        if pending.approval_id != approval_id {
193            return false;
194        }
195
196        self.pending = None;
197        self.status = Status::Idle;
198        let result = format!("Rejected: {}", reason.unwrap_or("No reason"));
199        self.last_result = Some(result.clone());
200
201        // Notify ClientRunner via emitter
202        self.send_result_with_level(&result, "warn");
203        true
204    }
205
206    /// Sends a result with a specific level via the emitter.
207    fn send_result_with_level(&self, result: &str, level: &str) {
208        if let Some(emitter) = &self.emitter {
209            emitter.emit_output_with_level(result, level);
210        }
211    }
212
213    /// Handles a Modify signal.
214    fn handle_modify(&mut self, approval_id: &str, modified_payload: &Value) -> bool {
215        let Some(pending) = &self.pending else {
216            return false;
217        };
218
219        if pending.approval_id != approval_id {
220            return false;
221        }
222
223        // Use modified message if provided, otherwise use original
224        let message = modified_payload
225            .get("message")
226            .and_then(|v| v.as_str())
227            .unwrap_or(&pending.message)
228            .to_string();
229
230        self.pending = None;
231        let result = self.execute_echo(&message);
232
233        // Send result to ClientRunner via Event
234        self.send_result(&result);
235        true
236    }
237}
238
239impl Default for EchoWithHilComponent {
240    fn default() -> Self {
241        Self::new()
242    }
243}
244
245impl Component for EchoWithHilComponent {
246    fn id(&self) -> &ComponentId {
247        &self.id
248    }
249
250    fn subscriptions(&self) -> &[EventCategory] {
251        &[EventCategory::Echo, EventCategory::Lifecycle]
252    }
253
254    fn status(&self) -> Status {
255        self.status
256    }
257
258    fn on_request(&mut self, request: &Request) -> Result<Value, ComponentError> {
259        match request.operation.as_str() {
260            "echo" => {
261                // Support both formats:
262                // 1. Simple string: "hello"
263                // 2. Object with message and optional approval_id: {"message": "hello", "approval_id": "xxx"}
264                let (message, provided_approval_id) = if let Some(s) = request.payload.as_str() {
265                    (s.to_string(), None)
266                } else if let Some(obj) = request.payload.as_object() {
267                    let msg = obj
268                        .get("message")
269                        .and_then(|v| v.as_str())
270                        .ok_or_else(|| {
271                            ComponentError::InvalidPayload("Expected 'message' field".into())
272                        })?
273                        .to_string();
274                    let id = obj
275                        .get("approval_id")
276                        .and_then(|v| v.as_str())
277                        .map(String::from);
278                    (msg, id)
279                } else {
280                    return Err(ComponentError::InvalidPayload(
281                        "Expected string or object with 'message' field".into(),
282                    ));
283                };
284
285                let approval_id = self.submit_for_approval(message, provided_approval_id);
286
287                Ok(serde_json::json!({
288                    "status": "pending_approval",
289                    "approval_id": approval_id,
290                    "message": "Awaiting Human approval"
291                }))
292            }
293            "check" => {
294                if let Some(result) = &self.last_result {
295                    Ok(serde_json::json!({
296                        "status": "completed",
297                        "result": result
298                    }))
299                } else if self.pending.is_some() {
300                    Ok(serde_json::json!({
301                        "status": "pending_approval"
302                    }))
303                } else {
304                    Ok(serde_json::json!({
305                        "status": "idle"
306                    }))
307                }
308            }
309            _ => Err(ComponentError::NotSupported(request.operation.clone())),
310        }
311    }
312
313    fn on_signal(&mut self, signal: &Signal) -> SignalResponse {
314        match &signal.kind {
315            SignalKind::Veto => {
316                self.abort();
317                SignalResponse::Abort
318            }
319            SignalKind::Approve { approval_id } => {
320                if self.handle_approve(approval_id) {
321                    SignalResponse::Handled
322                } else {
323                    SignalResponse::Ignored
324                }
325            }
326            SignalKind::Reject {
327                approval_id,
328                reason,
329            } => {
330                if self.handle_reject(approval_id, reason.as_deref()) {
331                    SignalResponse::Handled
332                } else {
333                    SignalResponse::Ignored
334                }
335            }
336            SignalKind::Modify {
337                approval_id,
338                modified_payload,
339            } => {
340                if self.handle_modify(approval_id, modified_payload) {
341                    SignalResponse::Handled
342                } else {
343                    SignalResponse::Ignored
344                }
345            }
346            _ => SignalResponse::Ignored,
347        }
348    }
349
350    fn abort(&mut self) {
351        self.status = Status::Aborted;
352        self.pending = None;
353    }
354
355    fn set_emitter(&mut self, emitter: Box<dyn Emitter>) {
356        self.emitter = Some(emitter);
357    }
358
359    fn as_packageable(&self) -> Option<&dyn Packageable> {
360        Some(self)
361    }
362
363    fn as_packageable_mut(&mut self) -> Option<&mut dyn Packageable> {
364        Some(self)
365    }
366}
367
368impl Packageable for EchoWithHilComponent {
369    fn list_packages(&self) -> &[PackageInfo] {
370        &self.installed_packages
371    }
372
373    fn install_package(&mut self, package: &Package) -> Result<(), PackageError> {
374        // Check if already installed
375        if self.is_installed(package.id()) {
376            return Err(PackageError::AlreadyInstalled(package.id().to_string()));
377        }
378
379        // Parse decorator config from package content
380        let config: DecoratorConfig = package.to_content()?;
381
382        // Apply decorator
383        self.decorator = config;
384        self.installed_packages.push(package.info.clone());
385
386        tracing::info!(
387            "Installed package '{}': prefix='{}', suffix='{}'",
388            package.id(),
389            self.decorator.prefix,
390            self.decorator.suffix
391        );
392
393        Ok(())
394    }
395
396    fn uninstall_package(&mut self, package_id: &str) -> Result<(), PackageError> {
397        // Check if installed
398        if !self.is_installed(package_id) {
399            return Err(PackageError::NotFound(package_id.to_string()));
400        }
401
402        // Remove from list
403        self.installed_packages.retain(|p| p.id != package_id);
404
405        // Reset decorator to default
406        self.decorator = DecoratorConfig::default();
407
408        tracing::info!("Uninstalled package '{}'", package_id);
409
410        Ok(())
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use orcs_types::{ChannelId, Principal, PrincipalId};
418
419    fn test_user() -> Principal {
420        Principal::User(PrincipalId::new())
421    }
422
423    fn test_request(operation: &str, payload: Value) -> Request {
424        Request::new(
425            EventCategory::Echo,
426            operation,
427            ComponentId::builtin("test"),
428            ChannelId::new(),
429            payload,
430        )
431    }
432
433    #[test]
434    fn echo_with_hil_creation() {
435        let comp = EchoWithHilComponent::new();
436        assert_eq!(comp.id().name, "echo_with_hil");
437        assert_eq!(comp.status(), Status::Idle);
438        assert!(!comp.has_pending());
439        assert!(comp.last_result().is_none());
440    }
441
442    #[test]
443    fn subscriptions_include_echo() {
444        let comp = EchoWithHilComponent::new();
445        let subs = comp.subscriptions();
446        assert!(subs.contains(&EventCategory::Echo));
447        assert!(subs.contains(&EventCategory::Lifecycle));
448    }
449
450    #[test]
451    fn echo_request_creates_pending_approval() {
452        let mut comp = EchoWithHilComponent::new();
453
454        let req = test_request("echo", Value::String("Hi".into()));
455        let result = comp.on_request(&req);
456
457        assert!(result.is_ok());
458        let response = result.expect("echo request should succeed");
459        assert_eq!(response["status"], "pending_approval");
460        assert!(response["approval_id"].is_string());
461
462        assert!(comp.has_pending());
463        assert_eq!(comp.status(), Status::Running);
464    }
465
466    #[test]
467    fn pending_has_description() {
468        let mut comp = EchoWithHilComponent::new();
469
470        let req = test_request("echo", Value::String("Hi".into()));
471        comp.on_request(&req).expect("echo request should succeed");
472
473        assert_eq!(comp.pending_description(), Some("You say 'Hi'?"));
474    }
475
476    #[test]
477    fn echo_approved_produces_output() {
478        let mut comp = EchoWithHilComponent::new();
479
480        let req = test_request("echo", Value::String("Hi".into()));
481        let result = comp.on_request(&req).expect("echo request should succeed");
482        let approval_id = result["approval_id"]
483            .as_str()
484            .expect("approval_id should be a string")
485            .to_string();
486
487        assert!(comp.has_pending());
488        assert!(comp.last_result().is_none());
489
490        let signal = Signal::approve(&approval_id, test_user());
491        let response = comp.on_signal(&signal);
492
493        assert_eq!(response, SignalResponse::Handled);
494        assert!(!comp.has_pending());
495        assert_eq!(comp.last_result(), Some("Echo: Hi"));
496        assert_eq!(comp.status(), Status::Idle);
497    }
498
499    #[test]
500    fn echo_rejected_no_output() {
501        let mut comp = EchoWithHilComponent::new();
502
503        let req = test_request("echo", Value::String("Bad message".into()));
504        let result = comp.on_request(&req).expect("echo request should succeed");
505        let approval_id = result["approval_id"]
506            .as_str()
507            .expect("approval_id should be a string")
508            .to_string();
509
510        let signal = Signal::reject(&approval_id, Some("Not allowed".into()), test_user());
511        let response = comp.on_signal(&signal);
512
513        assert_eq!(response, SignalResponse::Handled);
514        assert!(!comp.has_pending());
515        assert!(comp
516            .last_result()
517            .expect("should have result after rejection")
518            .starts_with("Rejected:"));
519        assert_eq!(comp.status(), Status::Idle);
520    }
521
522    #[test]
523    fn echo_modified_uses_new_message() {
524        let mut comp = EchoWithHilComponent::new();
525
526        let req = test_request("echo", Value::String("Original".into()));
527        let result = comp.on_request(&req).expect("echo request should succeed");
528        let approval_id = result["approval_id"]
529            .as_str()
530            .expect("approval_id should be a string")
531            .to_string();
532
533        let modified_payload = serde_json::json!({ "message": "Modified" });
534        let signal = Signal::modify(&approval_id, modified_payload, test_user());
535        let response = comp.on_signal(&signal);
536
537        assert_eq!(response, SignalResponse::Handled);
538        assert!(!comp.has_pending());
539        assert_eq!(comp.last_result(), Some("Echo: Modified"));
540    }
541
542    #[test]
543    fn echo_veto_aborts() {
544        let mut comp = EchoWithHilComponent::new();
545
546        let req = test_request("echo", Value::String("Hi".into()));
547        let _ = comp.on_request(&req);
548
549        let signal = Signal::veto(test_user());
550        let response = comp.on_signal(&signal);
551
552        assert_eq!(response, SignalResponse::Abort);
553        assert_eq!(comp.status(), Status::Aborted);
554        assert!(!comp.has_pending());
555    }
556
557    #[test]
558    fn echo_check_status() {
559        let mut comp = EchoWithHilComponent::new();
560
561        // Check idle state
562        let req = test_request("check", Value::Null);
563        let result = comp.on_request(&req).expect("check request should succeed");
564        assert_eq!(result["status"], "idle");
565
566        // Start echo
567        let echo_req = test_request("echo", Value::String("Hi".into()));
568        let echo_result = comp
569            .on_request(&echo_req)
570            .expect("echo request should succeed");
571        let approval_id = echo_result["approval_id"]
572            .as_str()
573            .expect("approval_id should be a string")
574            .to_string();
575
576        // Check pending state
577        let result = comp
578            .on_request(&req)
579            .expect("check request should succeed while pending");
580        assert_eq!(result["status"], "pending_approval");
581
582        // Approve
583        let signal = Signal::approve(&approval_id, test_user());
584        comp.on_signal(&signal);
585
586        // Check completed state
587        let result = comp
588            .on_request(&req)
589            .expect("check request should succeed after approval");
590        assert_eq!(result["status"], "completed");
591        assert_eq!(result["result"], "Echo: Hi");
592    }
593
594    #[test]
595    fn echo_invalid_payload() {
596        let mut comp = EchoWithHilComponent::new();
597
598        let req = test_request("echo", serde_json::json!({"not": "string"}));
599        let result = comp.on_request(&req);
600
601        assert!(result.is_err());
602        match result.expect_err("should return InvalidPayload error for missing message field") {
603            ComponentError::InvalidPayload(_) => {}
604            other => panic!("Expected InvalidPayload error, got: {:?}", other),
605        }
606    }
607
608    #[test]
609    fn echo_unsupported_operation() {
610        let mut comp = EchoWithHilComponent::new();
611
612        let req = test_request("unknown", Value::Null);
613        let result = comp.on_request(&req);
614
615        assert!(result.is_err());
616        match result.expect_err("should return NotSupported error for unknown operation") {
617            ComponentError::NotSupported(op) => assert_eq!(op, "unknown"),
618            other => panic!("Expected NotSupported error, got: {:?}", other),
619        }
620    }
621
622    #[test]
623    fn signal_wrong_approval_id_ignored() {
624        let mut comp = EchoWithHilComponent::new();
625
626        let req = test_request("echo", Value::String("Hi".into()));
627        let _ = comp.on_request(&req);
628
629        // Wrong approval_id
630        let signal = Signal::approve("wrong-id", test_user());
631        let response = comp.on_signal(&signal);
632
633        assert_eq!(response, SignalResponse::Ignored);
634        assert!(comp.has_pending()); // Still pending
635    }
636
637    #[test]
638    fn signal_no_pending_ignored() {
639        let mut comp = EchoWithHilComponent::new();
640
641        // No pending request
642        let signal = Signal::approve("any-id", test_user());
643        let response = comp.on_signal(&signal);
644
645        assert_eq!(response, SignalResponse::Ignored);
646    }
647
648    /// E2E test: Full user interaction flow
649    #[test]
650    fn e2e_user_says_hi_and_approves() {
651        let mut comp = EchoWithHilComponent::new();
652
653        // User: "Hi"
654        let req = test_request("echo", Value::String("Hi".into()));
655        let result = comp.on_request(&req).expect("echo request should succeed");
656
657        // System prompts: "You say 'Hi'?"
658        assert_eq!(result["status"], "pending_approval");
659        let approval_id = result["approval_id"]
660            .as_str()
661            .expect("approval_id should be a string");
662
663        // Verify the approval description
664        assert_eq!(comp.pending_description(), Some("You say 'Hi'?"));
665
666        // User: "y" (approve)
667        let signal = Signal::approve(approval_id, test_user());
668        comp.on_signal(&signal);
669
670        // System: "Echo: Hi"
671        assert_eq!(comp.last_result(), Some("Echo: Hi"));
672    }
673
674    /// E2E test: User rejects
675    #[test]
676    fn e2e_user_says_bad_word_and_rejects() {
677        let mut comp = EchoWithHilComponent::new();
678
679        // User: "BadWord"
680        let req = test_request("echo", Value::String("BadWord".into()));
681        let result = comp.on_request(&req).expect("echo request should succeed");
682        let approval_id = result["approval_id"]
683            .as_str()
684            .expect("approval_id should be a string");
685
686        // System prompts: "You say 'BadWord'?"
687        assert_eq!(comp.pending_description(), Some("You say 'BadWord'?"));
688
689        // User: "n" (reject)
690        let signal = Signal::reject(approval_id, Some("Changed my mind".into()), test_user());
691        comp.on_signal(&signal);
692
693        // No echo output
694        assert!(comp
695            .last_result()
696            .expect("should have result after rejection")
697            .contains("Rejected"));
698    }
699
700    // --- Package tests ---
701
702    fn create_decorator_package(id: &str, prefix: &str, suffix: &str) -> Package {
703        let info = PackageInfo::new(id, "Decorator", "1.0.0", "Add decoration to echo output");
704        let config = DecoratorConfig {
705            prefix: prefix.to_string(),
706            suffix: suffix.to_string(),
707        };
708        Package::new(info, &config).expect("package creation should succeed with valid config")
709    }
710
711    #[test]
712    fn package_install_decorator() {
713        let mut comp = EchoWithHilComponent::new();
714
715        let package = create_decorator_package("angle-brackets", "<<", ">>");
716        comp.install_package(&package)
717            .expect("package install should succeed");
718
719        assert!(comp.is_installed("angle-brackets"));
720        assert_eq!(comp.decorator().prefix, "<<");
721        assert_eq!(comp.decorator().suffix, ">>");
722        assert_eq!(comp.list_packages().len(), 1);
723    }
724
725    #[test]
726    fn package_uninstall_decorator() {
727        let mut comp = EchoWithHilComponent::new();
728
729        let package = create_decorator_package("stars", "***", "***");
730        comp.install_package(&package)
731            .expect("package install should succeed");
732        comp.uninstall_package("stars")
733            .expect("package uninstall should succeed");
734
735        assert!(!comp.is_installed("stars"));
736        assert_eq!(comp.decorator().prefix, "");
737        assert_eq!(comp.decorator().suffix, "");
738        assert!(comp.list_packages().is_empty());
739    }
740
741    #[test]
742    fn package_already_installed_error() {
743        let mut comp = EchoWithHilComponent::new();
744
745        let package = create_decorator_package("test-pkg", "[", "]");
746        comp.install_package(&package)
747            .expect("first install should succeed");
748
749        let result = comp.install_package(&package);
750        assert!(matches!(result, Err(PackageError::AlreadyInstalled(_))));
751    }
752
753    #[test]
754    fn package_not_found_error() {
755        let mut comp = EchoWithHilComponent::new();
756
757        let result = comp.uninstall_package("nonexistent");
758        assert!(matches!(result, Err(PackageError::NotFound(_))));
759    }
760
761    #[test]
762    fn package_decorator_applied_to_echo() {
763        let mut comp = EchoWithHilComponent::new();
764
765        // Install decorator
766        let package = create_decorator_package("brackets", "[", "]");
767        comp.install_package(&package)
768            .expect("package install should succeed");
769
770        // Request echo
771        let req = test_request("echo", Value::String("Hello".into()));
772        let result = comp.on_request(&req).expect("echo request should succeed");
773        let approval_id = result["approval_id"]
774            .as_str()
775            .expect("approval_id should be a string");
776
777        // Approve
778        let signal = Signal::approve(approval_id, test_user());
779        comp.on_signal(&signal);
780
781        // Verify decorated output
782        assert_eq!(comp.last_result(), Some("Echo: [Hello]"));
783    }
784
785    #[test]
786    fn package_uninstall_removes_decoration() {
787        let mut comp = EchoWithHilComponent::new();
788
789        // Install and then uninstall
790        let package = create_decorator_package("brackets", "[", "]");
791        comp.install_package(&package)
792            .expect("package install should succeed");
793        comp.uninstall_package("brackets")
794            .expect("package uninstall should succeed");
795
796        // Request echo
797        let req = test_request("echo", Value::String("Hello".into()));
798        let result = comp.on_request(&req).expect("echo request should succeed");
799        let approval_id = result["approval_id"]
800            .as_str()
801            .expect("approval_id should be a string");
802
803        // Approve
804        let signal = Signal::approve(approval_id, test_user());
805        comp.on_signal(&signal);
806
807        // Verify no decoration
808        assert_eq!(comp.last_result(), Some("Echo: Hello"));
809    }
810}