Skip to main content

a3s_code_core/session_review/
scenario.rs

1//! Pluggable review scenarios — hosts and Use packages supply rubrics here.
2//!
3//! Code owns fencing, persistence, and lifecycle. Scenarios own evidence
4//! collection and evaluation. Core must never `match` on scenario business
5//! rules beyond registry lookup.
6
7use super::{
8    ReviewSubjectV1, SessionReviewError, SessionReviewFindingV1, SessionReviewSeverityV1,
9    SessionReviewStatusV1, SessionReviewStoreV1,
10};
11use std::collections::HashMap;
12use std::sync::{Arc, RwLock};
13
14/// Well-known built-in scenario id for transcript sticky / science reply review.
15pub const SCENARIO_REPLY_TRANSCRIPT: &str = "reply.transcript";
16
17/// Host scenario for Desktop / CLI sticky reply review (rubrics stay in the host).
18pub struct ReplyTranscriptScenario;
19
20impl ReviewScenario for ReplyTranscriptScenario {
21    fn scenario_id(&self) -> &str {
22        SCENARIO_REPLY_TRANSCRIPT
23    }
24
25    fn triggers(&self) -> ReviewTriggerPolicy {
26        ReviewTriggerPolicy::AfterMainSuccess
27    }
28
29    fn injects_into_main(&self) -> bool {
30        true
31    }
32}
33
34/// Register the built-in reply.transcript scenario (idempotent replace).
35pub fn register_default_scenarios(
36    registry: &ReviewScenarioRegistry,
37) -> Result<(), SessionReviewError> {
38    registry.register(Arc::new(ReplyTranscriptScenario))
39}
40
41/// When a scenario may be armed by the host runtime.
42#[derive(Clone, Copy, Debug, Eq, PartialEq)]
43pub enum ReviewTriggerPolicy {
44    /// After a successful main-agent turn (Auto-review).
45    AfterMainSuccess,
46    /// Periodic / TurnEnd during long work.
47    TurnEndPeriodic,
48    /// Explicit user or host request only.
49    ExplicitRequest,
50    /// Scenario is registered but never auto-armed by Code.
51    Never,
52}
53
54/// Draft finding produced by a scenario before Core assigns session identity.
55#[derive(Clone, Debug, Eq, PartialEq)]
56pub struct ReviewFindingDraft {
57    pub finding_id: String,
58    pub severity: SessionReviewSeverityV1,
59    pub category: String,
60    pub claim: String,
61    pub evidence: String,
62    pub subject: ReviewSubjectV1,
63    pub quote: Option<String>,
64    pub suggestion: Option<String>,
65}
66
67/// Host-supplied review scenario. Rubrics live here — not in Core.
68pub trait ReviewScenario: Send + Sync {
69    fn scenario_id(&self) -> &str;
70
71    fn triggers(&self) -> ReviewTriggerPolicy {
72        ReviewTriggerPolicy::ExplicitRequest
73    }
74
75    /// Whether pending findings from this scenario inject into the main prompt.
76    fn injects_into_main(&self) -> bool {
77        true
78    }
79}
80
81/// Process-local registry of review scenarios for one session runtime.
82#[derive(Default)]
83pub struct ReviewScenarioRegistry {
84    scenarios: RwLock<HashMap<String, Arc<dyn ReviewScenario>>>,
85}
86
87impl ReviewScenarioRegistry {
88    pub fn new() -> Self {
89        Self::default()
90    }
91
92    pub fn register(&self, scenario: Arc<dyn ReviewScenario>) -> Result<(), SessionReviewError> {
93        let id = scenario.scenario_id().to_owned();
94        super::validate_id("scenarioId", &id)?;
95        let mut guard = self
96            .scenarios
97            .write()
98            .map_err(|_| SessionReviewError::Serialization("scenario registry poisoned".into()))?;
99        guard.insert(id, scenario);
100        Ok(())
101    }
102
103    pub fn get(&self, scenario_id: &str) -> Option<Arc<dyn ReviewScenario>> {
104        self.scenarios
105            .read()
106            .ok()
107            .and_then(|guard| guard.get(scenario_id).cloned())
108    }
109
110    pub fn contains(&self, scenario_id: &str) -> bool {
111        self.scenarios
112            .read()
113            .ok()
114            .is_some_and(|guard| guard.contains_key(scenario_id))
115    }
116
117    pub fn scenario_ids(&self) -> Vec<String> {
118        self.scenarios
119            .read()
120            .map(|guard| {
121                let mut ids: Vec<_> = guard.keys().cloned().collect();
122                ids.sort();
123                ids
124            })
125            .unwrap_or_default()
126    }
127}
128
129/// Admit scenario drafts into the durable session review store.
130pub fn admit_finding_drafts(
131    store: &mut SessionReviewStoreV1,
132    scenario_id: &str,
133    source_review_id: &str,
134    observed_at_ms: u64,
135    drafts: Vec<ReviewFindingDraft>,
136) -> Result<usize, SessionReviewError> {
137    super::validate_id("scenarioId", scenario_id)?;
138    super::validate_id("sourceReviewId", source_review_id)?;
139    let mut admitted = 0;
140    for draft in drafts {
141        let mut finding = SessionReviewFindingV1::new(
142            draft.finding_id,
143            store.session_id.clone(),
144            draft.severity,
145            draft.category,
146            draft.claim,
147            draft.evidence,
148            draft.subject,
149            source_review_id,
150            observed_at_ms,
151        )?;
152        finding = finding.with_scenario_id(scenario_id)?;
153        if let Some(quote) = draft.quote {
154            finding = finding.with_quote(quote)?;
155        }
156        if let Some(suggestion) = draft.suggestion {
157            finding = finding.with_suggestion(suggestion)?;
158        }
159        // New admissions must be pending — enforce via upsert.
160        debug_assert!(matches!(finding.status, SessionReviewStatusV1::Pending));
161        store.upsert(finding)?;
162        admitted += 1;
163    }
164    Ok(admitted)
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::session_review::SessionReviewAnchorV1;
171
172    struct DummyScenario(&'static str);
173
174    impl ReviewScenario for DummyScenario {
175        fn scenario_id(&self) -> &str {
176            self.0
177        }
178
179        fn triggers(&self) -> ReviewTriggerPolicy {
180            ReviewTriggerPolicy::AfterMainSuccess
181        }
182    }
183
184    #[test]
185    fn registry_registers_and_lists_scenarios() {
186        let registry = ReviewScenarioRegistry::new();
187        registry
188            .register(Arc::new(DummyScenario("reply.transcript")))
189            .unwrap();
190        registry
191            .register(Arc::new(DummyScenario("office.contract")))
192            .unwrap();
193        assert!(registry.contains("office.contract"));
194        assert_eq!(
195            registry.scenario_ids(),
196            vec!["office.contract".to_owned(), "reply.transcript".to_owned()]
197        );
198    }
199
200    #[test]
201    fn register_default_scenarios_installs_reply_transcript() {
202        let registry = ReviewScenarioRegistry::new();
203        register_default_scenarios(&registry).unwrap();
204        assert!(registry.contains(SCENARIO_REPLY_TRANSCRIPT));
205        let scenario = registry.get(SCENARIO_REPLY_TRANSCRIPT).unwrap();
206        assert!(scenario.injects_into_main());
207        assert_eq!(scenario.triggers(), ReviewTriggerPolicy::AfterMainSuccess);
208    }
209
210    #[test]
211    fn admit_drafts_tags_scenario_and_stays_pending() {
212        let mut store = SessionReviewStoreV1::empty("session-1").unwrap();
213        let draft = ReviewFindingDraft {
214            finding_id: "f-1".into(),
215            severity: SessionReviewSeverityV1::Warning,
216            category: "numeric".into(),
217            claim: "claim".into(),
218            evidence: "evidence".into(),
219            subject: ReviewSubjectV1::transcript(SessionReviewAnchorV1::new("turn-1").unwrap()),
220            quote: None,
221            suggestion: None,
222        };
223        let n = admit_finding_drafts(
224            &mut store,
225            "office.contract",
226            "review-job-9",
227            1_000,
228            vec![draft],
229        )
230        .unwrap();
231        assert_eq!(n, 1);
232        assert_eq!(store.findings[0].scenario_id, "office.contract");
233        assert_eq!(store.findings[0].status, SessionReviewStatusV1::Pending);
234        assert_eq!(store.pending_for_scenario("office.contract").len(), 1);
235        assert!(store.pending_for_scenario("reply.transcript").is_empty());
236    }
237}