1use std::{fmt, sync::Arc};
4
5use runifold_core::{CapabilitySet, RunContext};
6use runifold_model::{ContentPart, FinishReason, Message, ModelResponse};
7use serde::{Deserialize, Serialize};
8use serde_json::{Value, json};
9use sha2::{Digest, Sha256};
10use thiserror::Error;
11
12use crate::AgentFuture;
13
14const MAX_REVIEW_FEEDBACK_BYTES: usize = 65_536;
15const MAX_REVIEW_REQUEST_BYTES: usize = 1_048_576;
16const MAX_REJECTION_REASON_BYTES: usize = 4_096;
17const MAX_REVIEWER_NAME_BYTES: usize = 128;
18const MAX_REVIEW_ERROR_BYTES: usize = 4_096;
19const MAX_DESCRIPTOR_CONFIGURATION_BYTES: usize = 65_536;
20
21#[derive(Clone)]
22pub(crate) struct TerminalReviewConfig {
23 pub(crate) reviewer: Arc<dyn TerminalReviewer>,
24 pub(crate) descriptor: TerminalReviewerDescriptor,
25 pub(crate) policy: TerminalReviewPolicy,
26 pub(crate) capabilities: CapabilitySet,
27}
28
29#[derive(Clone)]
30pub(crate) struct TurnReviewConfig {
31 pub(crate) reviewer: Arc<dyn TurnReviewer>,
32 pub(crate) descriptor: TerminalReviewerDescriptor,
33 pub(crate) policy: TurnReviewPolicy,
34 pub(crate) capabilities: CapabilitySet,
35}
36
37impl fmt::Debug for TurnReviewConfig {
38 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
39 formatter
40 .debug_struct("TurnReviewConfig")
41 .field("descriptor", &self.descriptor)
42 .field("policy", &self.policy)
43 .field("capabilities", &self.capabilities)
44 .finish_non_exhaustive()
45 }
46}
47
48impl fmt::Debug for TerminalReviewConfig {
49 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
50 formatter
51 .debug_struct("TerminalReviewConfig")
52 .field("descriptor", &self.descriptor)
53 .field("policy", &self.policy)
54 .field("capabilities", &self.capabilities)
55 .finish_non_exhaustive()
56 }
57}
58
59#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
61pub struct TerminalReviewerDescriptor {
62 name: String,
63 version: String,
64 configuration_sha256: String,
65}
66
67impl TerminalReviewerDescriptor {
68 pub fn new(
75 name: impl Into<String>,
76 version: impl Into<String>,
77 configuration: &Value,
78 ) -> Result<Self, TerminalReviewError> {
79 let name = name.into();
80 let version = version.into();
81 validate_identifier("terminal reviewer name", &name)?;
82 validate_identifier("terminal reviewer version", &version)?;
83 let encoded = serde_json::to_vec(configuration)
84 .map_err(|error| TerminalReviewError::InvalidConfiguration(error.to_string()))?;
85 if encoded.len() > MAX_DESCRIPTOR_CONFIGURATION_BYTES {
86 return Err(TerminalReviewError::InvalidConfiguration(format!(
87 "terminal reviewer configuration exceeds {MAX_DESCRIPTOR_CONFIGURATION_BYTES} bytes"
88 )));
89 }
90 let digest = Sha256::digest(encoded);
91 let configuration_sha256 = lowercase_hex(&digest);
92 Ok(Self {
93 name,
94 version,
95 configuration_sha256,
96 })
97 }
98
99 pub fn name(&self) -> &str {
101 &self.name
102 }
103
104 pub fn version(&self) -> &str {
106 &self.version
107 }
108
109 pub fn configuration_sha256(&self) -> &str {
111 &self.configuration_sha256
112 }
113}
114
115#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
121pub struct TerminalReviewPolicy {
122 max_repairs: u32,
123}
124
125impl TerminalReviewPolicy {
126 pub const fn new(max_repairs: u32) -> Self {
128 Self { max_repairs }
129 }
130
131 pub const fn max_repairs(self) -> u32 {
133 self.max_repairs
134 }
135}
136
137#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
139#[serde(rename_all = "snake_case")]
140#[non_exhaustive]
141pub enum TurnReviewScope {
142 #[default]
145 IntermediateOnly,
146 EveryModelResponse,
148}
149
150impl TurnReviewScope {
151 pub(crate) fn includes(self, response: &ModelResponse) -> bool {
152 match self {
153 Self::EveryModelResponse => true,
154 Self::IntermediateOnly => {
155 response
156 .content
157 .iter()
158 .any(|part| matches!(part, ContentPart::ToolCall(_)))
159 || matches!(
160 &response.finish_reason,
161 FinishReason::Other(reason) if reason == "pause_turn"
162 )
163 }
164 }
165 }
166}
167
168#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
173pub struct TurnReviewPolicy {
174 max_repairs: u32,
175 scope: TurnReviewScope,
176}
177
178impl TurnReviewPolicy {
179 pub const fn new(max_repairs: u32) -> Self {
181 Self {
182 max_repairs,
183 scope: TurnReviewScope::IntermediateOnly,
184 }
185 }
186
187 #[must_use]
189 pub const fn with_scope(mut self, scope: TurnReviewScope) -> Self {
190 self.scope = scope;
191 self
192 }
193
194 pub const fn max_repairs(self) -> u32 {
196 self.max_repairs
197 }
198
199 pub const fn scope(self) -> TurnReviewScope {
201 self.scope
202 }
203}
204
205#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
207pub struct TerminalReviewRequest {
208 pub agent: String,
210 pub turn: u32,
212 pub attempt: u32,
214 pub transcript: Vec<Message>,
216 pub candidate: ModelResponse,
218}
219
220impl TerminalReviewRequest {
221 pub fn validate(&self) -> Result<(), TerminalReviewError> {
228 let bytes = serde_json::to_vec(self)
229 .map_err(|error| TerminalReviewError::InvalidVerdict(error.to_string()))?
230 .len();
231 if bytes > MAX_REVIEW_REQUEST_BYTES {
232 return Err(TerminalReviewError::RequestTooLarge {
233 bytes,
234 maximum: MAX_REVIEW_REQUEST_BYTES,
235 });
236 }
237 Ok(())
238 }
239}
240
241#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
243pub struct TurnReviewRequest {
244 pub agent: String,
246 pub turn: u32,
248 pub transcript: Vec<Message>,
250 pub candidate: ModelResponse,
252}
253
254impl TurnReviewRequest {
255 pub fn validate(&self) -> Result<(), TerminalReviewError> {
262 let bytes = serde_json::to_vec(self)
263 .map_err(|error| TerminalReviewError::InvalidVerdict(error.to_string()))?
264 .len();
265 if bytes > MAX_REVIEW_REQUEST_BYTES {
266 return Err(TerminalReviewError::RequestTooLarge {
267 bytes,
268 maximum: MAX_REVIEW_REQUEST_BYTES,
269 });
270 }
271 Ok(())
272 }
273}
274
275#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
277#[serde(rename_all = "snake_case")]
278#[non_exhaustive]
279pub enum TerminalReviewVerdictKind {
280 Approve,
282 Repair,
284 Reject,
286}
287
288impl TerminalReviewVerdictKind {
289 pub(crate) const fn as_str(self) -> &'static str {
290 match self {
291 Self::Approve => "approve",
292 Self::Repair => "repair",
293 Self::Reject => "reject",
294 }
295 }
296}
297
298#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
300#[serde(tag = "verdict", rename_all = "snake_case")]
301#[non_exhaustive]
302pub enum TerminalReviewVerdict {
303 Approve,
305 Repair {
307 feedback: Value,
309 },
310 Reject {
312 reason: String,
314 },
315}
316
317impl TerminalReviewVerdict {
318 pub const fn approve() -> Self {
320 Self::Approve
321 }
322
323 pub fn repair(feedback: Value) -> Result<Self, TerminalReviewError> {
330 let verdict = Self::Repair { feedback };
331 verdict.validate()?;
332 Ok(verdict)
333 }
334
335 pub fn reject(reason: impl Into<String>) -> Result<Self, TerminalReviewError> {
342 let verdict = Self::Reject {
343 reason: reason.into(),
344 };
345 verdict.validate()?;
346 Ok(verdict)
347 }
348
349 pub fn validate(&self) -> Result<(), TerminalReviewError> {
356 match self {
357 Self::Approve => Ok(()),
358 Self::Repair { feedback } => {
359 if feedback.is_null() {
360 return Err(TerminalReviewError::InvalidVerdict(
361 "repair feedback cannot be null".into(),
362 ));
363 }
364 let length = serde_json::to_vec(feedback)
365 .map_err(|error| TerminalReviewError::InvalidVerdict(error.to_string()))?
366 .len();
367 if length > MAX_REVIEW_FEEDBACK_BYTES {
368 return Err(TerminalReviewError::InvalidVerdict(format!(
369 "repair feedback exceeds {MAX_REVIEW_FEEDBACK_BYTES} bytes"
370 )));
371 }
372 Ok(())
373 }
374 Self::Reject { reason } => validate_bounded_text(
375 "rejection reason",
376 reason,
377 MAX_REJECTION_REASON_BYTES,
378 TerminalReviewError::InvalidVerdict,
379 ),
380 }
381 }
382
383 pub const fn kind(&self) -> TerminalReviewVerdictKind {
385 match self {
386 Self::Approve => TerminalReviewVerdictKind::Approve,
387 Self::Repair { .. } => TerminalReviewVerdictKind::Repair,
388 Self::Reject { .. } => TerminalReviewVerdictKind::Reject,
389 }
390 }
391}
392
393#[derive(Clone, Debug, Error, Eq, PartialEq)]
395#[non_exhaustive]
396pub enum TerminalReviewError {
397 #[error("invalid terminal reviewer configuration: {0}")]
399 InvalidConfiguration(String),
400 #[error("terminal review request is {bytes} bytes; maximum is {maximum}")]
402 RequestTooLarge {
403 bytes: usize,
405 maximum: usize,
407 },
408 #[error("terminal reviewer execution failed: {0}")]
410 Execution(String),
411 #[error("invalid terminal review verdict: {0}")]
413 InvalidVerdict(String),
414}
415
416impl TerminalReviewError {
417 pub(crate) fn bounded(self) -> Self {
418 match self {
419 Self::InvalidConfiguration(message) => {
420 Self::InvalidConfiguration(truncate_utf8(message, MAX_REVIEW_ERROR_BYTES))
421 }
422 Self::Execution(message) => {
423 Self::Execution(truncate_utf8(message, MAX_REVIEW_ERROR_BYTES))
424 }
425 Self::InvalidVerdict(message) => {
426 Self::InvalidVerdict(truncate_utf8(message, MAX_REVIEW_ERROR_BYTES))
427 }
428 Self::RequestTooLarge { bytes, maximum } => Self::RequestTooLarge { bytes, maximum },
429 }
430 }
431}
432
433pub type TerminalReviewFuture<'a> =
435 AgentFuture<'a, Result<TerminalReviewVerdict, TerminalReviewError>>;
436
437pub trait TerminalReviewer: Send + Sync {
439 fn descriptor(&self) -> &TerminalReviewerDescriptor;
441
442 fn review_terminal<'a>(
444 &'a self,
445 request: TerminalReviewRequest,
446 run: &'a RunContext,
447 ) -> TerminalReviewFuture<'a>;
448}
449
450pub type TurnReviewVerdict = TerminalReviewVerdict;
453
454pub type TurnReviewError = TerminalReviewError;
457
458pub type TurnReviewerDescriptor = TerminalReviewerDescriptor;
460
461pub type TurnReviewFuture<'a> = AgentFuture<'a, Result<TurnReviewVerdict, TurnReviewError>>;
463
464pub trait TurnReviewer: Send + Sync {
467 fn turn_descriptor(&self) -> &TurnReviewerDescriptor;
469
470 fn review_turn<'a>(
472 &'a self,
473 request: TurnReviewRequest,
474 run: &'a RunContext,
475 ) -> TurnReviewFuture<'a>;
476}
477
478impl<T> TurnReviewer for T
482where
483 T: TerminalReviewer + ?Sized,
484{
485 fn turn_descriptor(&self) -> &TurnReviewerDescriptor {
486 TerminalReviewer::descriptor(self)
487 }
488
489 fn review_turn<'a>(
490 &'a self,
491 request: TurnReviewRequest,
492 run: &'a RunContext,
493 ) -> TurnReviewFuture<'a> {
494 let terminal = TerminalReviewRequest {
495 agent: request.agent,
496 turn: request.turn,
497 attempt: 1,
498 transcript: request.transcript,
499 candidate: request.candidate,
500 };
501 self.review_terminal(terminal, run)
502 }
503}
504
505type TurnRuleFunction =
506 dyn Fn(&TurnReviewRequest) -> Result<TurnReviewVerdict, TurnReviewError> + Send + Sync;
507
508#[derive(Clone)]
510pub struct TurnRuleReviewer {
511 descriptor: TurnReviewerDescriptor,
512 rule: Arc<TurnRuleFunction>,
513}
514
515impl TurnRuleReviewer {
516 pub fn new<F>(
523 name: impl Into<String>,
524 version: impl Into<String>,
525 rule: F,
526 ) -> Result<Self, TurnReviewError>
527 where
528 F: Fn(&TurnReviewRequest) -> Result<TurnReviewVerdict, TurnReviewError>
529 + Send
530 + Sync
531 + 'static,
532 {
533 let name = name.into();
534 let version = version.into();
535 let descriptor = TurnReviewerDescriptor::new(name, version, &json!({"kind": "turn_rule"}))?;
536 Ok(Self {
537 descriptor,
538 rule: Arc::new(rule),
539 })
540 }
541
542 pub fn name(&self) -> &str {
544 self.descriptor.name()
545 }
546}
547
548impl TurnReviewer for TurnRuleReviewer {
549 fn turn_descriptor(&self) -> &TurnReviewerDescriptor {
550 &self.descriptor
551 }
552
553 fn review_turn<'a>(
554 &'a self,
555 request: TurnReviewRequest,
556 _run: &'a RunContext,
557 ) -> TurnReviewFuture<'a> {
558 let verdict = request
559 .validate()
560 .and_then(|()| (self.rule)(&request))
561 .and_then(|verdict| {
562 verdict.validate()?;
563 Ok(verdict)
564 });
565 Box::pin(async move { verdict })
566 }
567}
568
569impl fmt::Debug for TurnRuleReviewer {
570 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
571 formatter
572 .debug_struct("TurnRuleReviewer")
573 .field("descriptor", &self.descriptor)
574 .finish_non_exhaustive()
575 }
576}
577
578type RuleFunction = dyn Fn(&TerminalReviewRequest) -> Result<TerminalReviewVerdict, TerminalReviewError>
579 + Send
580 + Sync;
581
582#[derive(Clone)]
584pub struct TerminalRuleReviewer {
585 descriptor: TerminalReviewerDescriptor,
586 rule: Arc<RuleFunction>,
587}
588
589impl TerminalRuleReviewer {
590 pub fn new<F>(
597 name: impl Into<String>,
598 version: impl Into<String>,
599 rule: F,
600 ) -> Result<Self, TerminalReviewError>
601 where
602 F: Fn(&TerminalReviewRequest) -> Result<TerminalReviewVerdict, TerminalReviewError>
603 + Send
604 + Sync
605 + 'static,
606 {
607 let name = name.into();
608 let version = version.into();
609 let descriptor =
610 TerminalReviewerDescriptor::new(name, version, &json!({"kind": "terminal_rule"}))?;
611 Ok(Self {
612 descriptor,
613 rule: Arc::new(rule),
614 })
615 }
616
617 pub fn name(&self) -> &str {
619 self.descriptor.name()
620 }
621}
622
623impl TerminalReviewer for TerminalRuleReviewer {
624 fn descriptor(&self) -> &TerminalReviewerDescriptor {
625 &self.descriptor
626 }
627
628 fn review_terminal<'a>(
629 &'a self,
630 request: TerminalReviewRequest,
631 _run: &'a RunContext,
632 ) -> TerminalReviewFuture<'a> {
633 let verdict = request
634 .validate()
635 .and_then(|()| (self.rule)(&request))
636 .and_then(|verdict| {
637 verdict.validate()?;
638 Ok(verdict)
639 });
640 Box::pin(async move { verdict })
641 }
642}
643
644impl fmt::Debug for TerminalRuleReviewer {
645 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
646 formatter
647 .debug_struct("TerminalRuleReviewer")
648 .field("descriptor", &self.descriptor)
649 .finish_non_exhaustive()
650 }
651}
652
653#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
655#[serde(rename_all = "snake_case")]
656#[non_exhaustive]
657pub enum CompositeTerminalReviewMode {
658 #[default]
660 AllMustApprove,
661 FirstFailure,
663}
664
665#[derive(Clone)]
666struct TerminalReviewerEntry {
667 name: String,
668 reviewer: Arc<dyn TerminalReviewer>,
669}
670
671#[derive(Clone)]
673pub struct CompositeTerminalReviewer {
674 name: String,
675 version: String,
676 mode: CompositeTerminalReviewMode,
677 descriptor: TerminalReviewerDescriptor,
678 reviewers: Vec<TerminalReviewerEntry>,
679}
680
681impl CompositeTerminalReviewer {
682 pub fn new(
691 name: impl Into<String>,
692 version: impl Into<String>,
693 mode: CompositeTerminalReviewMode,
694 ) -> Result<Self, TerminalReviewError> {
695 let name = name.into();
696 let version = version.into();
697 let descriptor = composite_descriptor(&name, &version, mode, &[])?;
698 Ok(Self {
699 name,
700 version,
701 mode,
702 descriptor,
703 reviewers: Vec::new(),
704 })
705 }
706
707 pub fn push<R>(
714 &mut self,
715 name: impl Into<String>,
716 reviewer: R,
717 ) -> Result<(), TerminalReviewError>
718 where
719 R: TerminalReviewer + 'static,
720 {
721 self.push_shared(name, Arc::new(reviewer))
722 }
723
724 pub fn push_shared(
731 &mut self,
732 name: impl Into<String>,
733 reviewer: Arc<dyn TerminalReviewer>,
734 ) -> Result<(), TerminalReviewError> {
735 let name = name.into();
736 validate_identifier("composite terminal reviewer entry", &name)?;
737 if self.reviewers.iter().any(|entry| entry.name == name) {
738 return Err(TerminalReviewError::InvalidConfiguration(format!(
739 "duplicate composite terminal reviewer entry `{name}`"
740 )));
741 }
742 let mut reviewers = self.reviewers.clone();
743 reviewers.push(TerminalReviewerEntry { name, reviewer });
744 let descriptor = composite_descriptor(&self.name, &self.version, self.mode, &reviewers)?;
745 self.reviewers = reviewers;
746 self.descriptor = descriptor;
747 Ok(())
748 }
749
750 pub const fn mode(&self) -> CompositeTerminalReviewMode {
752 self.mode
753 }
754
755 pub fn len(&self) -> usize {
757 self.reviewers.len()
758 }
759
760 pub fn is_empty(&self) -> bool {
762 self.reviewers.is_empty()
763 }
764}
765
766impl TerminalReviewer for CompositeTerminalReviewer {
767 fn descriptor(&self) -> &TerminalReviewerDescriptor {
768 &self.descriptor
769 }
770
771 fn review_terminal<'a>(
772 &'a self,
773 request: TerminalReviewRequest,
774 run: &'a RunContext,
775 ) -> TerminalReviewFuture<'a> {
776 Box::pin(async move {
777 request.validate()?;
778 if self.reviewers.is_empty() {
779 return Err(TerminalReviewError::InvalidConfiguration(
780 "composite terminal reviewer requires at least one reviewer".into(),
781 ));
782 }
783 let mut repairs = Vec::new();
784 for entry in &self.reviewers {
785 let verdict = entry
786 .reviewer
787 .review_terminal(request.clone(), run)
788 .await
789 .map_err(TerminalReviewError::bounded)?;
790 verdict.validate().map_err(TerminalReviewError::bounded)?;
791 match verdict {
792 TerminalReviewVerdict::Approve => {}
793 TerminalReviewVerdict::Reject { reason } => {
794 return TerminalReviewVerdict::reject(reason);
795 }
796 TerminalReviewVerdict::Repair { feedback } => {
797 if self.mode == CompositeTerminalReviewMode::FirstFailure {
798 return TerminalReviewVerdict::repair(feedback);
799 }
800 repairs.push(json!({
801 "reviewer": entry.name,
802 "feedback": feedback,
803 }));
804 }
805 }
806 }
807 if repairs.is_empty() {
808 Ok(TerminalReviewVerdict::approve())
809 } else {
810 TerminalReviewVerdict::repair(json!({
811 "kind": "composite",
812 "reviews": repairs,
813 }))
814 }
815 })
816 }
817}
818
819impl fmt::Debug for CompositeTerminalReviewer {
820 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
821 formatter
822 .debug_struct("CompositeTerminalReviewer")
823 .field("name", &self.name)
824 .field("version", &self.version)
825 .field("descriptor", &self.descriptor)
826 .field("mode", &self.mode)
827 .field(
828 "reviewers",
829 &self
830 .reviewers
831 .iter()
832 .map(|entry| entry.name.as_str())
833 .collect::<Vec<_>>(),
834 )
835 .finish()
836 }
837}
838
839fn composite_descriptor(
840 name: &str,
841 version: &str,
842 mode: CompositeTerminalReviewMode,
843 reviewers: &[TerminalReviewerEntry],
844) -> Result<TerminalReviewerDescriptor, TerminalReviewError> {
845 TerminalReviewerDescriptor::new(
846 name,
847 version,
848 &json!({
849 "kind": "composite_terminal",
850 "mode": mode,
851 "reviewers": reviewers.iter().map(|entry| json!({
852 "entry": entry.name,
853 "descriptor": entry.reviewer.descriptor(),
854 })).collect::<Vec<_>>(),
855 }),
856 )
857}
858
859fn validate_identifier(field: &str, value: &str) -> Result<(), TerminalReviewError> {
860 if value.is_empty()
861 || value.len() > MAX_REVIEWER_NAME_BYTES
862 || !value
863 .bytes()
864 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
865 {
866 return Err(TerminalReviewError::InvalidConfiguration(format!(
867 "{field} must contain 1..={MAX_REVIEWER_NAME_BYTES} ASCII letters, digits, `_`, `-`, or `.`"
868 )));
869 }
870 Ok(())
871}
872
873fn truncate_utf8(mut value: String, maximum: usize) -> String {
874 if value.len() <= maximum {
875 return value;
876 }
877 let mut end = maximum;
878 while !value.is_char_boundary(end) {
879 end = end.saturating_sub(1);
880 }
881 value.truncate(end);
882 value
883}
884
885fn lowercase_hex(bytes: &[u8]) -> String {
886 const DIGITS: &[u8; 16] = b"0123456789abcdef";
887 bytes.iter().fold(
888 String::with_capacity(bytes.len().saturating_mul(2)),
889 |mut encoded, byte| {
890 encoded.push(char::from(DIGITS[usize::from(byte >> 4)]));
891 encoded.push(char::from(DIGITS[usize::from(byte & 0x0f)]));
892 encoded
893 },
894 )
895}
896
897pub(crate) fn validate_bounded_text(
898 field: &str,
899 value: &str,
900 maximum: usize,
901 error: fn(String) -> TerminalReviewError,
902) -> Result<(), TerminalReviewError> {
903 if value.trim().is_empty() {
904 return Err(error(format!("{field} cannot be blank")));
905 }
906 if value.len() > maximum {
907 return Err(error(format!("{field} exceeds {maximum} bytes")));
908 }
909 Ok(())
910}
911
912#[cfg(test)]
913mod tests {
914 use serde_json::{Value, json};
915
916 use super::{
917 TerminalReviewError, TerminalReviewVerdict, TerminalReviewerDescriptor,
918 TerminalRuleReviewer,
919 };
920
921 #[test]
922 fn repair_feedback_and_rejection_reasons_are_bounded() {
923 assert!(matches!(
924 TerminalReviewVerdict::repair(Value::Null),
925 Err(TerminalReviewError::InvalidVerdict(_))
926 ));
927 assert!(matches!(
928 TerminalReviewVerdict::repair(json!({"body": "x".repeat(65_537)})),
929 Err(TerminalReviewError::InvalidVerdict(_))
930 ));
931 assert!(matches!(
932 TerminalReviewVerdict::reject(" "),
933 Err(TerminalReviewError::InvalidVerdict(_))
934 ));
935 }
936
937 #[test]
938 fn rule_reviewer_requires_a_stable_name() {
939 let result = TerminalRuleReviewer::new("not a stable name", "v1", |_| {
940 Ok(TerminalReviewVerdict::approve())
941 });
942
943 assert!(matches!(
944 result,
945 Err(TerminalReviewError::InvalidConfiguration(_))
946 ));
947 }
948
949 #[test]
950 fn descriptor_fingerprint_changes_with_configuration() {
951 let first =
952 TerminalReviewerDescriptor::new("review", "v1", &json!({"threshold": 1})).unwrap();
953 let same =
954 TerminalReviewerDescriptor::new("review", "v1", &json!({"threshold": 1})).unwrap();
955 let changed =
956 TerminalReviewerDescriptor::new("review", "v1", &json!({"threshold": 2})).unwrap();
957
958 assert_eq!(first, same);
959 assert_ne!(first, changed);
960 assert_eq!(first.configuration_sha256().len(), 64);
961 }
962
963 #[test]
964 fn reviewer_execution_errors_are_unicode_safely_bounded() {
965 let bounded = TerminalReviewError::Execution("界".repeat(2_000)).bounded();
966
967 let TerminalReviewError::Execution(message) = bounded else {
968 panic!("execution error kind must be retained");
969 };
970 assert!(message.len() <= 4_096);
971 assert!(message.is_char_boundary(message.len()));
972 }
973}