1use super::scenario::SCENARIO_REPLY_TRANSCRIPT;
10use super::subject::ReviewSubjectV1;
11use super::{
12 clamp_session_review_text, validate_id, validate_multiline_text, SessionReviewError,
13 SESSION_REVIEW_MAX_TEXT_BYTES,
14};
15use serde::{Deserialize, Serialize};
16
17pub const SESSION_REVIEW_FINDING_SCHEMA_V1: &str = "a3s.code.session-review-finding.v1";
18
19fn default_scenario_id() -> String {
20 SCENARIO_REPLY_TRANSCRIPT.to_owned()
21}
22
23#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum SessionReviewStatusV1 {
27 Pending,
29 Addressed,
31 Accepted,
33 Waived,
35}
36
37impl SessionReviewStatusV1 {
38 pub const fn as_str(self) -> &'static str {
39 match self {
40 Self::Pending => "pending",
41 Self::Addressed => "addressed",
42 Self::Accepted => "accepted",
43 Self::Waived => "waived",
44 }
45 }
46
47 pub const fn is_terminal(self) -> bool {
48 matches!(self, Self::Accepted | Self::Waived)
49 }
50
51 pub const fn injects_into_main_prompt(self) -> bool {
53 matches!(self, Self::Pending)
54 }
55}
56
57#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum SessionReviewSeverityV1 {
61 Info,
62 Warning,
63 Error,
64 Blocker,
65}
66
67#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase", deny_unknown_fields)]
70pub struct SessionReviewAnchorV1 {
71 pub turn_id: String,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub run_id: Option<String>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub char_start: Option<u32>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub char_end: Option<u32>,
79}
80
81impl SessionReviewAnchorV1 {
82 pub fn new(turn_id: impl Into<String>) -> Result<Self, SessionReviewError> {
83 let anchor = Self {
84 turn_id: turn_id.into(),
85 run_id: None,
86 char_start: None,
87 char_end: None,
88 };
89 anchor.validate()?;
90 Ok(anchor)
91 }
92
93 pub fn with_run_id(mut self, run_id: impl Into<String>) -> Result<Self, SessionReviewError> {
94 let run_id = run_id.into();
95 validate_id("anchor.runId", &run_id)?;
96 self.run_id = Some(run_id);
97 Ok(self)
98 }
99
100 pub fn with_span(mut self, start: u32, end: u32) -> Result<Self, SessionReviewError> {
101 if end < start {
102 return Err(SessionReviewError::InvalidField("anchor.charEnd"));
103 }
104 self.char_start = Some(start);
105 self.char_end = Some(end);
106 Ok(self)
107 }
108
109 pub fn validate(&self) -> Result<(), SessionReviewError> {
110 validate_id("anchor.turnId", &self.turn_id)?;
111 if let Some(run_id) = &self.run_id {
112 validate_id("anchor.runId", run_id)?;
113 }
114 match (self.char_start, self.char_end) {
115 (None, None) => Ok(()),
116 (Some(start), Some(end)) if end >= start => Ok(()),
117 (Some(_), Some(_)) => Err(SessionReviewError::InvalidField("anchor.charEnd")),
118 (Some(_), None) | (None, Some(_)) => {
119 Err(SessionReviewError::InvalidField("anchor.span"))
120 }
121 }
122 }
123
124 pub const fn supports_strong_annotation(&self) -> bool {
126 self.char_start.is_some() && self.char_end.is_some()
127 }
128}
129
130#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
132#[serde(rename_all = "camelCase", deny_unknown_fields)]
133pub struct SessionReviewFindingV1 {
134 pub schema: String,
135 pub finding_id: String,
136 pub session_id: String,
137 #[serde(default = "default_scenario_id")]
139 pub scenario_id: String,
140 pub status: SessionReviewStatusV1,
141 pub severity: SessionReviewSeverityV1,
142 pub category: String,
143 pub claim: String,
144 pub evidence: String,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub quote: Option<String>,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub suggestion: Option<String>,
149 pub subject: ReviewSubjectV1,
150 pub source_review_id: String,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub addressed_by_run_id: Option<String>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub main_agent_reply: Option<String>,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub accepted_by_review_id: Option<String>,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub reopen_reason: Option<String>,
161 pub observed_at_ms: u64,
162 pub updated_at_ms: u64,
163}
164
165impl SessionReviewFindingV1 {
166 #[allow(clippy::too_many_arguments)]
167 pub fn new(
168 finding_id: impl Into<String>,
169 session_id: impl Into<String>,
170 severity: SessionReviewSeverityV1,
171 category: impl Into<String>,
172 claim: impl Into<String>,
173 evidence: impl Into<String>,
174 subject: ReviewSubjectV1,
175 source_review_id: impl Into<String>,
176 observed_at_ms: u64,
177 ) -> Result<Self, SessionReviewError> {
178 let finding = Self {
179 schema: SESSION_REVIEW_FINDING_SCHEMA_V1.to_owned(),
180 finding_id: finding_id.into(),
181 session_id: session_id.into(),
182 scenario_id: default_scenario_id(),
183 status: SessionReviewStatusV1::Pending,
184 severity,
185 category: category.into(),
186 claim: claim.into(),
187 evidence: evidence.into(),
188 quote: None,
189 suggestion: None,
190 subject,
191 source_review_id: source_review_id.into(),
192 addressed_by_run_id: None,
193 main_agent_reply: None,
194 accepted_by_review_id: None,
195 reopen_reason: None,
196 observed_at_ms,
197 updated_at_ms: observed_at_ms,
198 };
199 finding.validate()?;
200 Ok(finding)
201 }
202
203 #[allow(clippy::too_many_arguments)]
205 pub fn new_transcript(
206 finding_id: impl Into<String>,
207 session_id: impl Into<String>,
208 severity: SessionReviewSeverityV1,
209 category: impl Into<String>,
210 claim: impl Into<String>,
211 evidence: impl Into<String>,
212 anchor: SessionReviewAnchorV1,
213 source_review_id: impl Into<String>,
214 observed_at_ms: u64,
215 ) -> Result<Self, SessionReviewError> {
216 Self::new(
217 finding_id,
218 session_id,
219 severity,
220 category,
221 claim,
222 evidence,
223 ReviewSubjectV1::transcript(anchor),
224 source_review_id,
225 observed_at_ms,
226 )
227 }
228
229 pub fn with_scenario_id(
230 mut self,
231 scenario_id: impl Into<String>,
232 ) -> Result<Self, SessionReviewError> {
233 let scenario_id = scenario_id.into();
234 validate_id("scenarioId", &scenario_id)?;
235 self.scenario_id = scenario_id;
236 Ok(self)
237 }
238
239 pub fn with_quote(mut self, quote: impl Into<String>) -> Result<Self, SessionReviewError> {
240 let quote = quote.into();
241 validate_multiline_text("quote", "e, SESSION_REVIEW_MAX_TEXT_BYTES)?;
242 self.quote = Some(quote);
243 Ok(self)
244 }
245
246 pub fn with_suggestion(
247 mut self,
248 suggestion: impl Into<String>,
249 ) -> Result<Self, SessionReviewError> {
250 let suggestion = suggestion.into();
251 validate_multiline_text("suggestion", &suggestion, SESSION_REVIEW_MAX_TEXT_BYTES)?;
252 self.suggestion = Some(suggestion);
253 Ok(self)
254 }
255
256 pub fn transcript_anchor(&self) -> Option<&SessionReviewAnchorV1> {
257 self.subject.as_transcript_anchor()
258 }
259
260 pub fn validate(&self) -> Result<(), SessionReviewError> {
261 if self.schema != SESSION_REVIEW_FINDING_SCHEMA_V1 {
262 return Err(SessionReviewError::UnsupportedSchema);
263 }
264 validate_id("findingId", &self.finding_id)?;
265 validate_id("sessionId", &self.session_id)?;
266 validate_id("scenarioId", &self.scenario_id)?;
267 validate_id("category", &self.category)?;
268 validate_multiline_text("claim", &self.claim, SESSION_REVIEW_MAX_TEXT_BYTES)?;
269 validate_multiline_text("evidence", &self.evidence, SESSION_REVIEW_MAX_TEXT_BYTES)?;
270 if let Some(quote) = &self.quote {
271 validate_multiline_text("quote", quote, SESSION_REVIEW_MAX_TEXT_BYTES)?;
272 }
273 if let Some(suggestion) = &self.suggestion {
274 validate_multiline_text("suggestion", suggestion, SESSION_REVIEW_MAX_TEXT_BYTES)?;
275 }
276 if let Some(reply) = &self.main_agent_reply {
277 validate_multiline_text("mainAgentReply", reply, SESSION_REVIEW_MAX_TEXT_BYTES)?;
278 }
279 self.subject.validate()?;
280 validate_id("sourceReviewId", &self.source_review_id)?;
281 if self.observed_at_ms == 0 {
282 return Err(SessionReviewError::InvalidField("observedAtMs"));
283 }
284 if self.updated_at_ms < self.observed_at_ms {
285 return Err(SessionReviewError::InvalidField("updatedAtMs"));
286 }
287 match self.status {
288 SessionReviewStatusV1::Pending => {
289 if self.addressed_by_run_id.is_some() {
290 return Err(SessionReviewError::InvalidField("addressedByRunId"));
291 }
292 if self.accepted_by_review_id.is_some() {
293 return Err(SessionReviewError::InvalidField("acceptedByReviewId"));
294 }
295 }
296 SessionReviewStatusV1::Addressed => {
297 match &self.addressed_by_run_id {
298 Some(run_id) => validate_id("addressedByRunId", run_id)?,
299 None => return Err(SessionReviewError::InvalidField("addressedByRunId")),
300 }
301 if self.accepted_by_review_id.is_some() {
302 return Err(SessionReviewError::InvalidField("acceptedByReviewId"));
303 }
304 }
305 SessionReviewStatusV1::Accepted => {
306 match &self.addressed_by_run_id {
307 Some(run_id) => validate_id("addressedByRunId", run_id)?,
308 None => return Err(SessionReviewError::InvalidField("addressedByRunId")),
309 }
310 match &self.accepted_by_review_id {
311 Some(review_id) => validate_id("acceptedByReviewId", review_id)?,
312 None => return Err(SessionReviewError::InvalidField("acceptedByReviewId")),
313 }
314 }
315 SessionReviewStatusV1::Waived => {
316 if self.accepted_by_review_id.is_some() {
317 return Err(SessionReviewError::InvalidField("acceptedByReviewId"));
318 }
319 }
320 }
321 if let Some(reason) = &self.reopen_reason {
322 validate_multiline_text("reopenReason", reason, SESSION_REVIEW_MAX_TEXT_BYTES)?;
323 }
324 Ok(())
325 }
326
327 pub fn mark_addressed(
329 &mut self,
330 run_id: impl Into<String>,
331 at_ms: u64,
332 ) -> Result<(), SessionReviewError> {
333 self.validate()?;
334 if !matches!(self.status, SessionReviewStatusV1::Pending) {
335 return Err(SessionReviewError::InvalidTransition {
336 from: self.status.as_str(),
337 to: SessionReviewStatusV1::Addressed.as_str(),
338 });
339 }
340 let run_id = run_id.into();
341 validate_id("addressedByRunId", &run_id)?;
342 if at_ms < self.updated_at_ms {
343 return Err(SessionReviewError::InvalidField("updatedAtMs"));
344 }
345 self.status = SessionReviewStatusV1::Addressed;
346 self.addressed_by_run_id = Some(run_id);
347 self.accepted_by_review_id = None;
348 self.reopen_reason = None;
349 self.updated_at_ms = at_ms;
350 self.validate()
351 }
352
353 pub fn set_main_agent_reply(
355 &mut self,
356 reply: impl Into<String>,
357 ) -> Result<(), SessionReviewError> {
358 let reply = clamp_session_review_text(reply.into().trim(), SESSION_REVIEW_MAX_TEXT_BYTES);
359 if reply.is_empty() {
360 self.main_agent_reply = None;
361 return Ok(());
362 }
363 validate_multiline_text("mainAgentReply", &reply, SESSION_REVIEW_MAX_TEXT_BYTES)?;
364 self.main_agent_reply = Some(reply);
365 Ok(())
366 }
367
368 pub fn accept(
370 &mut self,
371 review_id: impl Into<String>,
372 at_ms: u64,
373 ) -> Result<(), SessionReviewError> {
374 self.validate()?;
375 if !matches!(self.status, SessionReviewStatusV1::Addressed) {
376 return Err(SessionReviewError::InvalidTransition {
377 from: self.status.as_str(),
378 to: SessionReviewStatusV1::Accepted.as_str(),
379 });
380 }
381 let review_id = review_id.into();
382 validate_id("acceptedByReviewId", &review_id)?;
383 if at_ms < self.updated_at_ms {
384 return Err(SessionReviewError::InvalidField("updatedAtMs"));
385 }
386 self.status = SessionReviewStatusV1::Accepted;
387 self.accepted_by_review_id = Some(review_id);
388 self.reopen_reason = None;
389 self.updated_at_ms = at_ms;
390 self.validate()
391 }
392
393 pub fn reopen(
395 &mut self,
396 reason: impl Into<String>,
397 at_ms: u64,
398 ) -> Result<(), SessionReviewError> {
399 self.validate()?;
400 if !matches!(self.status, SessionReviewStatusV1::Addressed) {
401 return Err(SessionReviewError::InvalidTransition {
402 from: self.status.as_str(),
403 to: SessionReviewStatusV1::Pending.as_str(),
404 });
405 }
406 let reason = reason.into();
407 validate_multiline_text("reopenReason", &reason, SESSION_REVIEW_MAX_TEXT_BYTES)?;
408 if at_ms < self.updated_at_ms {
409 return Err(SessionReviewError::InvalidField("updatedAtMs"));
410 }
411 self.status = SessionReviewStatusV1::Pending;
412 self.addressed_by_run_id = None;
413 self.main_agent_reply = None;
414 self.accepted_by_review_id = None;
415 self.reopen_reason = Some(reason);
416 self.updated_at_ms = at_ms;
417 self.validate()
418 }
419
420 pub fn waive(&mut self, at_ms: u64) -> Result<(), SessionReviewError> {
422 self.validate()?;
423 if !matches!(self.status, SessionReviewStatusV1::Pending) {
424 return Err(SessionReviewError::InvalidTransition {
425 from: self.status.as_str(),
426 to: SessionReviewStatusV1::Waived.as_str(),
427 });
428 }
429 if at_ms < self.updated_at_ms {
430 return Err(SessionReviewError::InvalidField("updatedAtMs"));
431 }
432 self.status = SessionReviewStatusV1::Waived;
433 self.addressed_by_run_id = None;
434 self.main_agent_reply = None;
435 self.accepted_by_review_id = None;
436 self.reopen_reason = None;
437 self.updated_at_ms = at_ms;
438 self.validate()
439 }
440
441 pub fn from_slice(bytes: &[u8]) -> Result<Self, SessionReviewError> {
442 let finding: Self = super::decode_json_slice(bytes)?;
443 finding.validate()?;
444 Ok(finding)
445 }
446
447 pub fn to_vec(&self) -> Result<Vec<u8>, SessionReviewError> {
448 self.validate()?;
449 super::encode_json(self)
450 }
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456
457 fn sample_finding() -> SessionReviewFindingV1 {
458 let anchor = SessionReviewAnchorV1::new("turn-1")
459 .unwrap()
460 .with_span(10, 20)
461 .unwrap();
462 SessionReviewFindingV1::new_transcript(
463 "finding-1",
464 "session-1",
465 SessionReviewSeverityV1::Warning,
466 "numeric",
467 "Claimed p=0.01 without tool output",
468 "No tool record contained a p-value",
469 anchor,
470 "review-job-1",
471 1_000,
472 )
473 .unwrap()
474 }
475
476 #[test]
477 fn new_finding_defaults_to_pending_and_injects() {
478 let finding = sample_finding();
479 assert_eq!(finding.status, SessionReviewStatusV1::Pending);
480 assert_eq!(finding.scenario_id, SCENARIO_REPLY_TRANSCRIPT);
481 assert!(finding.status.injects_into_main_prompt());
482 assert!(finding.subject.supports_strong_annotation());
483 }
484
485 #[test]
486 fn lifecycle_pending_addressed_accepted() {
487 let mut finding = sample_finding();
488 finding.mark_addressed("run-2", 1_100).unwrap();
489 assert_eq!(finding.status, SessionReviewStatusV1::Addressed);
490 assert!(!finding.status.injects_into_main_prompt());
491 finding.accept("review-job-2", 1_200).unwrap();
492 assert_eq!(finding.status, SessionReviewStatusV1::Accepted);
493 assert!(finding.status.is_terminal());
494 }
495
496 #[test]
497 fn lifecycle_addressed_reopen_returns_pending_with_reason() {
498 let mut finding = sample_finding();
499 finding.mark_addressed("run-2", 1_100).unwrap();
500 finding
501 .set_main_agent_reply("Recomputed the count via tool:1")
502 .unwrap();
503 assert_eq!(
504 finding.main_agent_reply.as_deref(),
505 Some("Recomputed the count via tool:1")
506 );
507 finding
508 .reopen("p-value still missing from evidence", 1_200)
509 .unwrap();
510 assert_eq!(finding.status, SessionReviewStatusV1::Pending);
511 assert!(finding.status.injects_into_main_prompt());
512 assert_eq!(
513 finding.reopen_reason.as_deref(),
514 Some("p-value still missing from evidence")
515 );
516 assert!(finding.addressed_by_run_id.is_none());
517 assert!(finding.main_agent_reply.is_none());
518 }
519
520 #[test]
521 fn waive_from_pending_is_terminal() {
522 let mut finding = sample_finding();
523 finding.waive(1_100).unwrap();
524 assert_eq!(finding.status, SessionReviewStatusV1::Waived);
525 assert!(finding.status.is_terminal());
526 assert!(!finding.status.injects_into_main_prompt());
527 }
528
529 #[test]
530 fn reject_accept_without_address_and_waive_from_addressed() {
531 let mut finding = sample_finding();
532 assert!(matches!(
533 finding.accept("review-2", 1_100),
534 Err(SessionReviewError::InvalidTransition { .. })
535 ));
536 finding.mark_addressed("run-2", 1_100).unwrap();
537 assert!(matches!(
538 finding.waive(1_200),
539 Err(SessionReviewError::InvalidTransition { .. })
540 ));
541 }
542
543 #[test]
544 fn claim_may_contain_newlines_but_not_nul() {
545 let anchor = SessionReviewAnchorV1::new("turn-1").unwrap();
546 let ok = SessionReviewFindingV1::new_transcript(
547 "finding-2",
548 "session-1",
549 SessionReviewSeverityV1::Info,
550 "citation",
551 "line1\nline2",
552 "evidence ok",
553 anchor.clone(),
554 "review-1",
555 1,
556 );
557 assert!(ok.is_ok());
558 let bad = SessionReviewFindingV1::new_transcript(
559 "finding-3",
560 "session-1",
561 SessionReviewSeverityV1::Info,
562 "citation",
563 "bad\0claim",
564 "evidence ok",
565 anchor,
566 "review-1",
567 1,
568 );
569 assert_eq!(bad, Err(SessionReviewError::InvalidField("claim")));
570 }
571
572 #[test]
573 fn round_trip_wire_helpers() {
574 let finding = sample_finding();
575 let bytes = finding.to_vec().unwrap();
576 let decoded = SessionReviewFindingV1::from_slice(&bytes).unwrap();
577 assert_eq!(decoded, finding);
578 }
579
580 #[test]
581 fn span_requires_both_ends_and_order() {
582 let anchor = SessionReviewAnchorV1::new("turn-1").unwrap();
583 assert!(anchor.clone().with_span(5, 4).is_err());
584 let mut partial = anchor;
585 partial.char_start = Some(1);
586 assert_eq!(
587 partial.validate(),
588 Err(SessionReviewError::InvalidField("anchor.span"))
589 );
590 }
591}