1use 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
45pub struct DecoratorConfig {
46 pub prefix: String,
48 pub suffix: String,
50}
51
52#[derive(Debug, Clone)]
54struct PendingEcho {
55 approval_id: String,
57 message: String,
59 description: String,
61}
62
63pub struct EchoWithHilComponent {
70 id: ComponentId,
71 status: Status,
72 pending: Option<PendingEcho>,
74 last_result: Option<String>,
76 installed_packages: Vec<PackageInfo>,
78 decorator: DecoratorConfig,
80 emitter: Option<Box<dyn Emitter>>,
82}
83
84impl EchoWithHilComponent {
85 #[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 #[must_use]
101 pub fn last_result(&self) -> Option<&str> {
102 self.last_result.as_deref()
103 }
104
105 #[must_use]
107 pub fn has_pending(&self) -> bool {
108 self.pending.is_some()
109 }
110
111 #[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 #[must_use]
119 pub fn pending_description(&self) -> Option<&str> {
120 self.pending.as_ref().map(|p| p.description.as_str())
121 }
122
123 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 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 #[must_use]
156 pub fn decorator(&self) -> &DecoratorConfig {
157 &self.decorator
158 }
159
160 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 self.send_result(&result);
176 true
177 }
178
179 fn send_result(&self, result: &str) {
181 if let Some(emitter) = &self.emitter {
182 emitter.emit_output(result);
183 }
184 }
185
186 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 self.send_result_with_level(&result, "warn");
203 true
204 }
205
206 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 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 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 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 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 if self.is_installed(package.id()) {
376 return Err(PackageError::AlreadyInstalled(package.id().to_string()));
377 }
378
379 let config: DecoratorConfig = package.to_content()?;
381
382 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 if !self.is_installed(package_id) {
399 return Err(PackageError::NotFound(package_id.to_string()));
400 }
401
402 self.installed_packages.retain(|p| p.id != package_id);
404
405 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 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 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 let result = comp
578 .on_request(&req)
579 .expect("check request should succeed while pending");
580 assert_eq!(result["status"], "pending_approval");
581
582 let signal = Signal::approve(&approval_id, test_user());
584 comp.on_signal(&signal);
585
586 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 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()); }
636
637 #[test]
638 fn signal_no_pending_ignored() {
639 let mut comp = EchoWithHilComponent::new();
640
641 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 #[test]
650 fn e2e_user_says_hi_and_approves() {
651 let mut comp = EchoWithHilComponent::new();
652
653 let req = test_request("echo", Value::String("Hi".into()));
655 let result = comp.on_request(&req).expect("echo request should succeed");
656
657 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 assert_eq!(comp.pending_description(), Some("You say 'Hi'?"));
665
666 let signal = Signal::approve(approval_id, test_user());
668 comp.on_signal(&signal);
669
670 assert_eq!(comp.last_result(), Some("Echo: Hi"));
672 }
673
674 #[test]
676 fn e2e_user_says_bad_word_and_rejects() {
677 let mut comp = EchoWithHilComponent::new();
678
679 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 assert_eq!(comp.pending_description(), Some("You say 'BadWord'?"));
688
689 let signal = Signal::reject(approval_id, Some("Changed my mind".into()), test_user());
691 comp.on_signal(&signal);
692
693 assert!(comp
695 .last_result()
696 .expect("should have result after rejection")
697 .contains("Rejected"));
698 }
699
700 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 let package = create_decorator_package("brackets", "[", "]");
767 comp.install_package(&package)
768 .expect("package install should succeed");
769
770 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 let signal = Signal::approve(approval_id, test_user());
779 comp.on_signal(&signal);
780
781 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 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 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 let signal = Signal::approve(approval_id, test_user());
805 comp.on_signal(&signal);
806
807 assert_eq!(comp.last_result(), Some("Echo: Hello"));
809 }
810}