1use serde::{Deserialize, Serialize};
30
31pub const MAX_FINAL_OUTPUT_BYTES: usize = 256 * 1024;
39
40#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum OnValidatorError {
50 #[default]
55 Reject,
56 Accept,
60}
61
62#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
71pub struct OutputSpec {
72 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub format: Option<String>,
79
80 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub instructions: Option<String>,
85
86 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub example: Option<String>,
90
91 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub schema: Option<serde_json::Value>,
102
103 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub validator: Option<String>,
115
116 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub on_validator_error: Option<OnValidatorError>,
122
123 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub overwrite_artifacts: Option<bool>,
130
131 #[serde(default, skip_serializing_if = "Vec::is_empty")]
135 pub artifacts: Vec<ArtifactSpec>,
136}
137
138impl OutputSpec {
139 pub fn is_empty(&self) -> bool {
142 self.format.is_none()
143 && self.instructions.is_none()
144 && self.example.is_none()
145 && self.schema.is_none()
146 && self.validator.is_none()
147 && self.on_validator_error.is_none()
148 && self.overwrite_artifacts.is_none()
149 && self.artifacts.is_empty()
150 }
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155pub struct ArtifactSpec {
156 pub name: String,
158 #[serde(rename = "type")]
160 pub mime_type: String,
161 #[serde(default)]
163 pub required: bool,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub description: Option<String>,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
176pub struct Artifact {
177 pub name: String,
179 pub path: String,
183 pub mime_type: crate::mime::MimeType,
185 #[serde(default)]
187 pub size: u64,
188 #[serde(default, skip_serializing_if = "String::is_empty")]
191 pub sha256: String,
192}
193
194impl Artifact {
195 pub fn short_label(&self) -> String {
197 format!(
198 "{} ({}, {})",
199 self.name,
200 self.mime_type,
201 crate::mime::human_size(self.size)
202 )
203 }
204
205 pub fn detail_columns(&self) -> String {
208 let sha = match self.sha256.is_empty() {
209 true => String::new(),
210 false => format!(
211 " sha256:{}",
212 self.sha256.chars().take(12).collect::<String>()
213 ),
214 };
215 format!(
216 "{} {} {}{sha}",
217 self.path,
218 self.mime_type,
219 crate::mime::human_size(self.size)
220 )
221 }
222
223 pub fn from_path(path: &str) -> Self {
226 let name = path
227 .rsplit(['/', '\\'])
228 .find(|s| !s.is_empty())
229 .unwrap_or(path)
230 .to_string();
231 Self {
232 name,
233 path: path.to_string(),
234 mime_type: crate::mime::octet_stream(),
235 size: 0,
236 sha256: String::new(),
237 }
238 }
239}
240
241#[derive(Deserialize)]
244#[serde(untagged)]
245enum ArtifactWire {
246 Full(Artifact),
247 Path(String),
248}
249
250fn artifacts_from_wire<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<Artifact>, D::Error> {
252 let listed: Vec<ArtifactWire> = Vec::deserialize(d)?;
253 Ok(listed
254 .into_iter()
255 .map(|a| match a {
256 ArtifactWire::Full(a) => a,
257 ArtifactWire::Path(p) => Artifact::from_path(&p),
258 })
259 .collect())
260}
261
262#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
273pub struct FinalOutput {
274 pub content: String,
276
277 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub format: Option<String>,
281
282 pub stage: String,
285
286 pub submitted_at: i64,
288
289 #[serde(default)]
291 pub truncated: bool,
292
293 #[serde(
301 default,
302 skip_serializing_if = "Vec::is_empty",
303 deserialize_with = "artifacts_from_wire"
304 )]
305 pub artifacts: Vec<Artifact>,
306}
307
308impl FinalOutput {
309 pub fn new(content: &str, format: Option<String>, stage: String, submitted_at: i64) -> Self {
316 let truncated = content.len() > MAX_FINAL_OUTPUT_BYTES;
317 let kept = crate::text::truncate_at_boundary(content, MAX_FINAL_OUTPUT_BYTES);
318 Self {
319 content: kept.to_string(),
320 format,
321 stage,
322 submitted_at,
323 truncated,
324 artifacts: Vec::new(),
325 }
326 }
327
328 pub fn with_artifacts(mut self, artifacts: Vec<Artifact>) -> Self {
330 self.artifacts = artifacts;
331 self
332 }
333
334 pub fn descriptor(&self) -> FinalOutputDescriptor {
336 FinalOutputDescriptor {
337 format: self.format.clone(),
338 stage: self.stage.clone(),
339 submitted_at: self.submitted_at,
340 bytes: self.content.len(),
341 truncated: self.truncated,
342 artifacts: self.artifacts.clone(),
343 }
344 }
345}
346
347#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
356pub struct FinalOutputDescriptor {
357 #[serde(default, skip_serializing_if = "Option::is_none")]
359 pub format: Option<String>,
360 pub stage: String,
362 pub submitted_at: i64,
364 #[serde(default)]
366 pub bytes: usize,
367 #[serde(default)]
369 pub truncated: bool,
370 #[serde(
372 default,
373 skip_serializing_if = "Vec::is_empty",
374 deserialize_with = "artifacts_from_wire"
375 )]
376 pub artifacts: Vec<Artifact>,
377}
378
379pub const FINAL_OUTPUT_FILE: &str = "final_output";
384
385pub fn resolve_output_spec(
401 agent: Option<&OutputSpec>,
402 stage: Option<&OutputSpec>,
403 request: Option<&OutputSpec>,
404) -> Option<OutputSpec> {
405 if agent.is_none() && stage.is_none() && request.is_none() {
406 return None;
407 }
408
409 fn field<T: Clone>(
410 agent: Option<&OutputSpec>,
411 stage: Option<&OutputSpec>,
412 request: Option<&OutputSpec>,
413 get: impl Fn(&OutputSpec) -> Option<T>,
414 ) -> Option<T> {
415 request
416 .and_then(&get)
417 .or_else(|| stage.and_then(&get))
418 .or_else(|| agent.and_then(&get))
419 }
420
421 let declared_format = field(agent, stage, None, |s| s.format.clone());
426 let requested_format = request.and_then(|r| r.format.clone());
427 let reshaped = requested_format.is_some() && requested_format != declared_format;
428
429 let shape_field = |get: fn(&OutputSpec) -> Option<serde_json::Value>| match reshaped {
430 true => request.and_then(get),
431 false => field(agent, stage, request, get),
432 };
433 let validator = match reshaped {
434 true => request.and_then(|r| r.validator.clone()),
435 false => field(agent, stage, request, |s| s.validator.clone()),
436 };
437 let on_validator_error = match reshaped {
440 true => request.and_then(|r| r.on_validator_error),
441 false => field(agent, stage, request, |s| s.on_validator_error),
442 };
443
444 let artifacts = match reshaped {
449 true => request.map(|r| r.artifacts.clone()).unwrap_or_default(),
450 false => field(agent, stage, request, |s| {
451 (!s.artifacts.is_empty()).then(|| s.artifacts.clone())
452 })
453 .unwrap_or_default(),
454 };
455
456 Some(OutputSpec {
457 format: field(agent, stage, request, |s| s.format.clone()),
458 instructions: field(agent, stage, request, |s| s.instructions.clone()),
459 example: field(agent, stage, request, |s| s.example.clone()),
460 schema: shape_field(|s| s.schema.clone()),
461 validator,
462 on_validator_error,
463 overwrite_artifacts: field(agent, stage, request, |s| s.overwrite_artifacts),
464 artifacts,
465 })
466}
467
468pub fn retired_check_warnings(
486 blueprint: &crate::Blueprint,
487 request: Option<&OutputSpec>,
488) -> Vec<String> {
489 let Some(requested) = request.and_then(|r| r.format.as_deref()) else {
490 return Vec::new();
491 };
492 let request_has_schema = request.is_some_and(|r| r.schema.is_some());
493 let mut groups: Vec<(RetiredChecks, Vec<String>)> = Vec::new();
494 for stage in &blueprint.stages {
495 let Some(retired) = retired_checks_for_stage(
496 blueprint.output.as_ref(),
497 stage.output.as_ref(),
498 requested,
499 request_has_schema,
500 ) else {
501 continue;
502 };
503 match groups.iter_mut().find(|(g, _)| *g == retired) {
504 Some((_, stages)) => stages.push(stage.name.clone()),
505 None => groups.push((retired, vec![stage.name.clone()])),
506 }
507 }
508 groups
509 .iter()
510 .map(|(checks, stages)| checks.warning_line(requested, stages, request_has_schema))
511 .collect()
512}
513
514#[derive(PartialEq, Eq)]
517struct RetiredChecks {
518 declared_format: Option<String>,
520 validator: Option<String>,
522 schema: bool,
524}
525
526fn retired_checks_for_stage(
532 agent: Option<&OutputSpec>,
533 stage: Option<&OutputSpec>,
534 requested: &str,
535 request_has_schema: bool,
536) -> Option<RetiredChecks> {
537 let declared =
538 |get: fn(&OutputSpec) -> Option<&str>| stage.and_then(get).or_else(|| agent.and_then(get));
539 let declared_format = declared(|s| s.format.as_deref());
540 if declared_format == Some(requested) {
541 return None;
542 }
543 let validator = declared(|s| s.validator.as_deref());
544 let schema = !request_has_schema
545 && stage
546 .and_then(|s| s.schema.as_ref())
547 .or_else(|| agent.and_then(|a| a.schema.as_ref()))
548 .is_some();
549 if validator.is_none() && !schema {
550 return None;
551 }
552 Some(RetiredChecks {
553 declared_format: declared_format.map(str::to_string),
554 validator: validator.map(str::to_string),
555 schema,
556 })
557}
558
559impl RetiredChecks {
560 fn warning_line(&self, requested: &str, stages: &[String], request_has_schema: bool) -> String {
565 let cause = match &self.declared_format {
566 Some(declared) => format!(
567 "requested output format '{requested}' differs from the declared '{declared}'"
568 ),
569 None => format!(
570 "requested output format '{requested}' reshapes an output declared without a \
571 format"
572 ),
573 };
574 let what = match (&self.validator, self.schema) {
575 (Some(v), true) => format!("the Rhai validator '{v}' and the JSON schema"),
576 (Some(v), false) => format!("the Rhai validator '{v}'"),
577 (None, _) => "the JSON schema".to_string(),
578 };
579 let tail = match request_has_schema {
580 true => "the schema supplied with the request is what checks the answer now",
581 false => {
582 "nothing checks the answer's shape; supply a schema with the request if the new \
583 shape needs one"
584 }
585 };
586 format!(
587 "{cause}: {what} declared for {} will not run, because a check written for one shape \
588 cannot judge another. Instead, {tail}.",
589 stage_phrase(stages)
590 )
591 }
592}
593
594fn stage_phrase(stages: &[String]) -> String {
597 let quoted: Vec<String> = stages.iter().map(|s| format!("'{s}'")).collect();
598 match quoted.split_last() {
599 Some((last, [])) => format!("stage {last}"),
600 Some((last, [first])) => format!("stages {first} and {last}"),
601 Some((last, head)) => format!("stages {}, and {last}", head.join(", ")),
602 None => "its stages".to_string(),
605 }
606}
607
608pub fn describe_spec(spec: &OutputSpec) -> String {
628 let mut parts = Vec::new();
629 if let Some(format) = &spec.format {
630 parts.push(format!("Return it in this format: {format}."));
631 }
632 if let Some(instructions) = &spec.instructions {
633 parts.push(instructions.clone());
634 }
635 if let Some(schema) = &spec.schema {
636 parts.push(format!(
637 "It must be JSON valid against this schema:\n{schema}"
638 ));
639 }
640 if let Some(example) = &spec.example {
641 parts.push(format!(
642 "Here is an example of the expected shape:\n{example}"
643 ));
644 }
645 if !spec.artifacts.is_empty() {
646 let listed: Vec<String> = spec
647 .artifacts
648 .iter()
649 .map(|a| {
650 let mut line = format!("- {} ({}", a.name, a.mime_type);
651 if a.required {
652 line.push_str(", required");
653 }
654 line.push(')');
655 if let Some(d) = &a.description {
656 line.push_str(": ");
657 line.push_str(d);
658 }
659 line
660 })
661 .collect();
662 parts.push(format!(
663 "Hand back these files in `artifacts`, each as {{ name, path }} with the name \
664 given here and the path of the file you wrote:\n{}",
665 listed.join("\n")
666 ));
667 }
668 if !parts.is_empty() {
669 parts.push(
670 "This governs how the answer is presented. Where anything else you were told says \
671 to present it differently - its length, its structure, what to lead with - follow \
672 this."
673 .to_string(),
674 );
675 }
676 parts.join("\n\n")
677}
678
679#[cfg(test)]
680mod tests {
681 use super::*;
682 use serde_json::json;
683
684 fn spec(format: Option<&str>, schema: Option<serde_json::Value>) -> OutputSpec {
685 OutputSpec {
686 format: format.map(str::to_string),
687 schema,
688 ..OutputSpec::default()
689 }
690 }
691
692 #[test]
697 fn artifacts_attach_to_a_submission_and_reach_the_descriptor() {
698 let output = FinalOutput::new(
699 "the summary",
700 Some("markdown".to_string()),
701 "present".to_string(),
702 42,
703 )
704 .with_artifacts(vec![
705 Artifact::from_path("data/dataset.csv"),
706 Artifact::from_path("report.pdf"),
707 ]);
708
709 assert_eq!(output.artifacts[0].path, "data/dataset.csv");
710 assert_eq!(output.artifacts[0].name, "dataset.csv");
711 assert_eq!(output.artifacts[1].name, "report.pdf");
712 assert_eq!(output.descriptor().artifacts, output.artifacts);
713 let old: FinalOutputDescriptor = serde_json::from_str(
715 "{\"stage\":\"s\",\"submitted_at\":1,\"artifacts\":[\"a/b.csv\",{\"name\":\"final\",\"path\":\"out.mp4\",\"mime_type\":\"video/mp4\",\"size\":9}]}",
716 )
717 .unwrap();
718 assert_eq!(old.artifacts[0].name, "b.csv");
719 assert_eq!(
720 old.artifacts[0].mime_type.as_str(),
721 "application/octet-stream"
722 );
723 assert_eq!(old.artifacts[1].name, "final");
724 assert_eq!(old.artifacts[1].size, 9);
725 assert_eq!(Artifact::from_path("").name, "");
726 assert!(
727 serde_json::from_str::<FinalOutputDescriptor>(
728 "{\"stage\":\"s\",\"submitted_at\":1,\"artifacts\":5}"
729 )
730 .is_err()
731 );
732 assert_eq!(Artifact::from_path("dir\\x.png").name, "x.png");
733 assert_eq!(output.descriptor().bytes, "the summary".len());
736 }
737
738 #[test]
739 fn a_submission_carries_no_artifacts_unless_given_some() {
740 assert!(
741 FinalOutput::new("x", None, "present".to_string(), 0)
742 .artifacts
743 .is_empty()
744 );
745 }
746
747 #[test]
748 fn declared_artifacts_cascade_whole_and_retire_on_a_reshape() {
749 let art = |name: &str| ArtifactSpec {
750 name: name.to_string(),
751 mime_type: "video/*".to_string(),
752 required: true,
753 description: None,
754 };
755 let agent = OutputSpec {
756 format: Some("markdown".to_string()),
757 artifacts: vec![art("agent-file")],
758 ..OutputSpec::default()
759 };
760 let stage = OutputSpec {
761 artifacts: vec![art("stage-file")],
762 ..OutputSpec::default()
763 };
764 let resolved = resolve_output_spec(Some(&agent), Some(&stage), None).unwrap();
766 assert_eq!(resolved.artifacts[0].name, "stage-file");
767 let resolved = resolve_output_spec(Some(&agent), None, None).unwrap();
768 assert_eq!(resolved.artifacts[0].name, "agent-file");
769 let request = OutputSpec {
771 format: Some("json".to_string()),
772 ..OutputSpec::default()
773 };
774 let resolved = resolve_output_spec(Some(&agent), Some(&stage), Some(&request)).unwrap();
775 assert!(resolved.artifacts.is_empty());
776 let request = OutputSpec {
778 format: Some("json".to_string()),
779 artifacts: vec![art("request-file")],
780 ..OutputSpec::default()
781 };
782 let resolved = resolve_output_spec(Some(&agent), Some(&stage), Some(&request)).unwrap();
783 assert_eq!(resolved.artifacts[0].name, "request-file");
784 assert!(!agent.is_empty());
785 }
786
787 #[test]
788 fn empty_spec_constrains_nothing() {
789 assert!(OutputSpec::default().is_empty());
790 assert!(!spec(Some("json"), None).is_empty());
791 assert!(!spec(None, Some(json!({}))).is_empty());
792 assert!(
793 !OutputSpec {
794 instructions: Some("be brief".to_string()),
795 ..OutputSpec::default()
796 }
797 .is_empty()
798 );
799 assert!(
800 !OutputSpec {
801 example: Some("<doc/>".to_string()),
802 ..OutputSpec::default()
803 }
804 .is_empty()
805 );
806 assert!(
807 !OutputSpec {
808 on_validator_error: Some(OnValidatorError::Accept),
809 ..OutputSpec::default()
810 }
811 .is_empty()
812 );
813 }
814
815 #[test]
816 fn no_level_asking_for_output_resolves_to_none() {
817 assert_eq!(resolve_output_spec(None, None, None), None);
818 }
819
820 #[test]
821 fn later_levels_win_field_by_field() {
822 let agent = OutputSpec {
823 format: Some("markdown".to_string()),
824 instructions: Some("agent guidance".to_string()),
825 example: Some("agent example".to_string()),
826 schema: None,
827 validator: None,
828 on_validator_error: None,
829 overwrite_artifacts: None,
830 artifacts: Vec::new(),
831 };
832 let stage = OutputSpec {
833 instructions: Some("stage guidance".to_string()),
834 ..OutputSpec::default()
835 };
836 let resolved = resolve_output_spec(Some(&agent), Some(&stage), None)
837 .expect("some level asked for an output");
838 assert_eq!(resolved.instructions.as_deref(), Some("stage guidance"));
840 assert_eq!(resolved.format.as_deref(), Some("markdown"));
841 assert_eq!(resolved.example.as_deref(), Some("agent example"));
842 }
843
844 #[test]
847 fn an_artifact_lists_its_columns_with_the_hash_only_when_stored() {
848 let mut artifact = Artifact::from_path("out/hero.png");
849 assert!(
850 artifact
851 .short_label()
852 .starts_with("hero.png (application/octet-stream, ")
853 );
854 let bare = artifact.detail_columns();
855 assert!(
856 bare.starts_with("out/hero.png application/octet-stream "),
857 "{bare}"
858 );
859 assert!(!bare.contains("sha256"));
860 artifact.sha256 = "abcdef0123456789".repeat(4);
861 assert!(artifact.detail_columns().ends_with(" sha256:abcdef012345"));
862 }
863
864 #[test]
865 fn a_stage_alone_can_ask_for_an_output() {
866 let stage = spec(Some("a2ui"), None);
867 let resolved =
868 resolve_output_spec(None, Some(&stage), None).expect("the stage asked for one");
869 assert_eq!(resolved.format.as_deref(), Some("a2ui"));
870 }
871
872 #[test]
876 fn re_stating_the_declared_format_keeps_its_shape_checks() {
877 let agent = OutputSpec {
878 format: Some("json".to_string()),
879 schema: Some(json!({"type": "object"})),
880 validator: Some("v.rhai".to_string()),
881 ..OutputSpec::default()
882 };
883 let request = spec(Some("json"), None);
884 let resolved = resolve_output_spec(Some(&agent), None, Some(&request))
885 .expect("the agent asked for one");
886 assert_eq!(resolved.schema, Some(json!({"type": "object"})));
887 assert_eq!(resolved.validator.as_deref(), Some("v.rhai"));
888 }
889
890 #[test]
894 fn reshaping_retires_the_validator_too() {
895 let agent = OutputSpec {
896 format: Some("a2ui".to_string()),
897 validator: Some("a2ui.rhai".to_string()),
898 on_validator_error: Some(OnValidatorError::Accept),
899 ..OutputSpec::default()
900 };
901 let request = spec(Some("xml"), None);
902 let resolved = resolve_output_spec(Some(&agent), None, Some(&request))
903 .expect("the agent asked for one");
904 assert_eq!(resolved.format.as_deref(), Some("xml"));
905 assert_eq!(resolved.validator, None);
906 assert_eq!(resolved.on_validator_error, None);
907 }
908
909 #[test]
912 fn the_stage_error_policy_overrides_the_agents() {
913 let agent = OutputSpec {
914 format: Some("a2ui".to_string()),
915 validator: Some("a2ui.rhai".to_string()),
916 on_validator_error: Some(OnValidatorError::Reject),
917 ..OutputSpec::default()
918 };
919 let stage = OutputSpec {
920 on_validator_error: Some(OnValidatorError::Accept),
921 ..OutputSpec::default()
922 };
923 let resolved =
924 resolve_output_spec(Some(&agent), Some(&stage), None).expect("the agent asked for one");
925 assert_eq!(resolved.on_validator_error, Some(OnValidatorError::Accept));
926 assert_eq!(
927 resolved.validator.as_deref(),
928 Some("a2ui.rhai"),
929 "the validator itself still falls through from the agent"
930 );
931 }
932
933 #[test]
937 fn the_overwrite_policy_cascades_field_by_field() {
938 let agent = OutputSpec {
939 format: Some("markdown".to_string()),
940 overwrite_artifacts: Some(true),
941 ..OutputSpec::default()
942 };
943 let stage = OutputSpec {
944 overwrite_artifacts: Some(false),
945 ..OutputSpec::default()
946 };
947 let resolved = resolve_output_spec(Some(&agent), None, None).unwrap();
948 assert_eq!(resolved.overwrite_artifacts, Some(true));
949 let resolved = resolve_output_spec(Some(&agent), Some(&stage), None).unwrap();
950 assert_eq!(resolved.overwrite_artifacts, Some(false));
951 let request = OutputSpec {
952 format: Some("json".to_string()),
953 overwrite_artifacts: Some(true),
954 ..OutputSpec::default()
955 };
956 let resolved = resolve_output_spec(Some(&agent), Some(&stage), Some(&request)).unwrap();
957 assert_eq!(resolved.overwrite_artifacts, Some(true));
958 let resolved = resolve_output_spec(None, Some(&OutputSpec::default()), None).unwrap();
959 assert_eq!(resolved.overwrite_artifacts, None);
960 assert!(
961 !OutputSpec {
962 overwrite_artifacts: Some(false),
963 ..OutputSpec::default()
964 }
965 .is_empty()
966 );
967 }
968
969 #[test]
972 fn a_reshaping_caller_can_supply_their_own_error_policy() {
973 let agent = OutputSpec {
974 format: Some("a2ui".to_string()),
975 validator: Some("a2ui.rhai".to_string()),
976 ..OutputSpec::default()
977 };
978 let request = OutputSpec {
979 format: Some("xml".to_string()),
980 validator: Some("xml.rhai".to_string()),
981 on_validator_error: Some(OnValidatorError::Accept),
982 ..OutputSpec::default()
983 };
984 let resolved = resolve_output_spec(Some(&agent), None, Some(&request))
985 .expect("the agent asked for one");
986 assert_eq!(resolved.validator.as_deref(), Some("xml.rhai"));
987 assert_eq!(resolved.on_validator_error, Some(OnValidatorError::Accept));
988 }
989
990 #[test]
992 fn a_caller_can_supply_shape_checks_with_its_own_format() {
993 let agent = OutputSpec {
994 format: Some("a2ui".to_string()),
995 validator: Some("a2ui.rhai".to_string()),
996 ..OutputSpec::default()
997 };
998 let request = OutputSpec {
999 format: Some("json".to_string()),
1000 schema: Some(json!({"type": "array"})),
1001 ..OutputSpec::default()
1002 };
1003 let resolved = resolve_output_spec(Some(&agent), None, Some(&request))
1004 .expect("the agent asked for one");
1005 assert_eq!(resolved.schema, Some(json!({"type": "array"})));
1006 assert_eq!(resolved.validator, None, "the agent's own is still retired");
1007 }
1008
1009 #[test]
1010 fn a_caller_reshaping_the_output_drops_the_declared_schema() {
1011 let agent = spec(Some("json"), Some(json!({"type": "object"})));
1012 let request = spec(Some("a2ui"), None);
1015 let resolved = resolve_output_spec(Some(&agent), None, Some(&request))
1016 .expect("the agent asked for one");
1017 assert_eq!(resolved.format.as_deref(), Some("a2ui"));
1018 assert_eq!(resolved.schema, None);
1019 }
1020
1021 #[test]
1022 fn a_caller_supplying_its_own_schema_keeps_it() {
1023 let agent = spec(Some("json"), Some(json!({"type": "object"})));
1024 let request = spec(Some("json"), Some(json!({"type": "array"})));
1025 let resolved = resolve_output_spec(Some(&agent), None, Some(&request))
1026 .expect("the agent asked for one");
1027 assert_eq!(resolved.schema, Some(json!({"type": "array"})));
1028 }
1029
1030 #[test]
1031 fn a_caller_that_names_no_format_leaves_the_schema_alone() {
1032 let agent = spec(Some("json"), Some(json!({"type": "object"})));
1033 let request = OutputSpec {
1035 instructions: Some("keep it short".to_string()),
1036 ..OutputSpec::default()
1037 };
1038 let resolved = resolve_output_spec(Some(&agent), None, Some(&request))
1039 .expect("the agent asked for one");
1040 assert_eq!(resolved.format.as_deref(), Some("json"));
1041 assert_eq!(resolved.schema, Some(json!({"type": "object"})));
1042 }
1043
1044 fn blueprint_with(agent: Option<&str>, stages: &[(&str, Option<&str>)]) -> crate::Blueprint {
1048 let mut manifest =
1049 String::from("[agent]\nname = \"checked\"\nversion = \"1.0.0\"\ndescription = \"d\"\n");
1050 if let Some(fields) = agent {
1051 manifest.push_str(&format!("\n[agent.output]\n{fields}\n"));
1052 }
1053 for (name, output) in stages {
1054 manifest.push_str(&format!("\n[stages.{name}]\nsystem_prompt = \"p\"\n"));
1055 if let Some(fields) = output {
1056 manifest.push_str(&format!("\n[stages.{name}.output]\n{fields}\n"));
1057 }
1058 }
1059 crate::manifest::parse_manifest(&manifest).expect("the test manifest parses")
1060 }
1061
1062 #[test]
1065 fn a_differing_format_earns_a_warning_naming_the_retired_validator() {
1066 let bp = blueprint_with(
1067 Some("format = \"markdown\"\nvalidator = \"checks/report.rhai\""),
1068 &[("plan", None)],
1069 );
1070 let request = spec(Some("json"), None);
1071 let warnings = retired_check_warnings(&bp, Some(&request));
1072 assert_eq!(warnings.len(), 1, "{warnings:?}");
1073 let line = &warnings[0];
1074 assert!(line.contains("'json'"), "{line}");
1075 assert!(line.contains("'markdown'"), "{line}");
1076 assert!(
1077 line.contains("the Rhai validator 'checks/report.rhai'"),
1078 "{line}"
1079 );
1080 assert!(line.contains("stage 'plan'"), "{line}");
1081 }
1082
1083 #[test]
1088 fn nothing_retired_means_nothing_warned() {
1089 let bp = blueprint_with(
1090 Some("format = \"markdown\"\nvalidator = \"v.rhai\"\nschema = { type = \"object\" }"),
1091 &[("plan", None)],
1092 );
1093 let restated = spec(Some("markdown"), None);
1094 assert!(retired_check_warnings(&bp, Some(&restated)).is_empty());
1095 let formatless = OutputSpec {
1096 instructions: Some("keep it short".to_string()),
1097 ..OutputSpec::default()
1098 };
1099 assert!(retired_check_warnings(&bp, Some(&formatless)).is_empty());
1100 assert!(retired_check_warnings(&bp, None).is_empty());
1101 let unchecked = blueprint_with(Some("format = \"markdown\""), &[("plan", None)]);
1103 let reshaped = spec(Some("json"), None);
1104 assert!(retired_check_warnings(&unchecked, Some(&reshaped)).is_empty());
1105 }
1106
1107 #[test]
1110 fn the_warning_names_exactly_what_is_lost() {
1111 let request = spec(Some("json"), None);
1112 let schema_only = blueprint_with(
1113 Some("format = \"markdown\"\nschema = { type = \"object\" }"),
1114 &[("plan", None)],
1115 );
1116 let warnings = retired_check_warnings(&schema_only, Some(&request));
1117 assert!(
1118 warnings[0].contains("the JSON schema declared"),
1119 "{warnings:?}"
1120 );
1121 let both = blueprint_with(
1122 Some("format = \"markdown\"\nvalidator = \"v.rhai\"\nschema = { type = \"object\" }"),
1123 &[("plan", None)],
1124 );
1125 let warnings = retired_check_warnings(&both, Some(&request));
1126 assert!(
1127 warnings[0].contains("the Rhai validator 'v.rhai' and the JSON schema"),
1128 "{warnings:?}"
1129 );
1130 }
1131
1132 #[test]
1136 fn a_replacement_schema_is_not_warned_about() {
1137 let bp = blueprint_with(
1138 Some("format = \"markdown\"\nvalidator = \"v.rhai\"\nschema = { type = \"object\" }"),
1139 &[("plan", None)],
1140 );
1141 let request = spec(Some("json"), Some(json!({"type": "array"})));
1142 let warnings = retired_check_warnings(&bp, Some(&request));
1143 assert_eq!(warnings.len(), 1, "{warnings:?}");
1144 assert!(
1145 warnings[0].contains("the Rhai validator 'v.rhai'"),
1146 "{warnings:?}"
1147 );
1148 assert!(
1149 !warnings[0].contains("JSON schema declared"),
1150 "{warnings:?}"
1151 );
1152 assert!(
1155 warnings[0].contains("the schema supplied with the request"),
1156 "{warnings:?}"
1157 );
1158 let schema_only = blueprint_with(
1161 Some("format = \"markdown\"\nschema = { type = \"object\" }"),
1162 &[("plan", None)],
1163 );
1164 assert!(retired_check_warnings(&schema_only, Some(&request)).is_empty());
1165 }
1166
1167 #[test]
1170 fn stages_losing_the_same_checks_share_one_line() {
1171 let bp = blueprint_with(
1172 Some("format = \"markdown\"\nvalidator = \"shared.rhai\""),
1173 &[
1174 ("plan", None),
1175 ("draft", None),
1176 ("wrap", Some("format = \"a2ui\"\nvalidator = \"a2ui.rhai\"")),
1177 ],
1178 );
1179 let request = spec(Some("json"), None);
1180 let warnings = retired_check_warnings(&bp, Some(&request));
1181 assert_eq!(warnings.len(), 2, "{warnings:?}");
1182 assert!(
1183 warnings[0].contains("stages 'plan' and 'draft'"),
1184 "{warnings:?}"
1185 );
1186 assert!(warnings[1].contains("stage 'wrap'"), "{warnings:?}");
1187 assert!(warnings[1].contains("'a2ui'"), "{warnings:?}");
1188 }
1189
1190 #[test]
1193 fn a_stage_level_declaration_retires_without_an_agent_one() {
1194 let bp = blueprint_with(
1195 None,
1196 &[(
1197 "plan",
1198 Some("format = \"markdown\"\nvalidator = \"v.rhai\""),
1199 )],
1200 );
1201 let request = spec(Some("json"), None);
1202 let warnings = retired_check_warnings(&bp, Some(&request));
1203 assert_eq!(warnings.len(), 1, "{warnings:?}");
1204 assert!(
1205 warnings[0].contains("the Rhai validator 'v.rhai'"),
1206 "{warnings:?}"
1207 );
1208 }
1209
1210 #[test]
1213 fn a_stage_already_in_the_requested_format_keeps_its_checks() {
1214 let bp = blueprint_with(
1215 Some("format = \"markdown\"\nvalidator = \"shared.rhai\""),
1216 &[("plan", None), ("emit", Some("format = \"json\""))],
1217 );
1218 let request = spec(Some("json"), None);
1219 let warnings = retired_check_warnings(&bp, Some(&request));
1220 assert_eq!(warnings.len(), 1, "{warnings:?}");
1221 assert!(warnings[0].contains("stage 'plan'"), "{warnings:?}");
1222 }
1223
1224 #[test]
1228 fn a_formatless_declaration_is_reshaped_by_any_request() {
1229 let bp = blueprint_with(Some("validator = \"v.rhai\""), &[("plan", None)]);
1230 let request = spec(Some("json"), None);
1231 let warnings = retired_check_warnings(&bp, Some(&request));
1232 assert_eq!(warnings.len(), 1, "{warnings:?}");
1233 assert!(
1234 warnings[0].contains("declared without a format"),
1235 "{warnings:?}"
1236 );
1237 }
1238
1239 #[test]
1241 fn stage_phrases_read_as_prose() {
1242 let names = |names: &[&str]| names.iter().map(|n| n.to_string()).collect::<Vec<_>>();
1243 assert_eq!(stage_phrase(&names(&["a"])), "stage 'a'");
1244 assert_eq!(stage_phrase(&names(&["a", "b"])), "stages 'a' and 'b'");
1245 assert_eq!(
1246 stage_phrase(&names(&["a", "b", "c"])),
1247 "stages 'a', 'b', and 'c'"
1248 );
1249 assert_eq!(stage_phrase(&[]), "its stages");
1250 }
1251
1252 #[test]
1253 fn short_content_is_stored_verbatim() {
1254 let out = FinalOutput::new(
1255 "done: 3 files",
1256 Some("markdown".to_string()),
1257 "wrap".into(),
1258 7,
1259 );
1260 assert_eq!(out.content, "done: 3 files");
1261 assert_eq!(out.format.as_deref(), Some("markdown"));
1262 assert_eq!(out.stage, "wrap");
1263 assert_eq!(out.submitted_at, 7);
1264 assert!(!out.truncated);
1265 }
1266
1267 #[test]
1268 fn oversized_content_is_cut_at_a_char_boundary_and_flagged() {
1269 let mut content = "a".repeat(MAX_FINAL_OUTPUT_BYTES - 1);
1272 content.push('\u{1f600}');
1273 let out = FinalOutput::new(&content, None, "wrap".into(), 0);
1274 assert!(out.truncated);
1275 assert_eq!(out.content.len(), MAX_FINAL_OUTPUT_BYTES - 1);
1276 assert!(out.format.is_none());
1277 }
1278
1279 #[test]
1280 fn describe_spec_is_empty_when_nothing_is_constrained() {
1281 assert_eq!(describe_spec(&OutputSpec::default()), "");
1282 }
1283
1284 #[test]
1285 fn describe_spec_renders_every_field_it_has() {
1286 let described = describe_spec(&OutputSpec {
1287 format: Some("a2ui".to_string()),
1288 instructions: Some("One card per finding.".to_string()),
1289 example: Some("{\"root\": {}}".to_string()),
1290 schema: Some(json!({"type": "object"})),
1291 validator: None,
1292 on_validator_error: None,
1293 overwrite_artifacts: None,
1294 artifacts: Vec::new(),
1295 });
1296 assert!(described.contains("Return it in this format: a2ui."));
1297 assert!(described.contains("One card per finding."));
1298 assert!(described.contains("valid against this schema"));
1299 assert!(described.contains("{\"root\": {}}"));
1300 let with_files = describe_spec(&OutputSpec {
1301 artifacts: vec![
1302 ArtifactSpec {
1303 name: "final".to_string(),
1304 mime_type: "video/mp4".to_string(),
1305 required: true,
1306 description: Some("the cut".to_string()),
1307 },
1308 ArtifactSpec {
1309 name: "notes".to_string(),
1310 mime_type: "text/*".to_string(),
1311 required: false,
1312 description: None,
1313 },
1314 ],
1315 ..OutputSpec::default()
1316 });
1317 assert!(
1318 with_files.contains("- final (video/mp4, required): the cut\n- notes (text/*)"),
1319 "{with_files}"
1320 );
1321 }
1322
1323 #[test]
1327 fn a_constrained_spec_says_it_outranks_the_stage_prompt() {
1328 let described = describe_spec(&OutputSpec {
1329 instructions: Some("Reply with only the integer.".to_string()),
1330 ..OutputSpec::default()
1331 });
1332 assert!(
1333 described.contains("Where anything else you were told"),
1334 "{described}"
1335 );
1336 assert!(
1339 described.trim_end().ends_with("follow this."),
1340 "{described}"
1341 );
1342 }
1343
1344 #[test]
1347 fn a_format_only_spec_claims_precedence_too() {
1348 let described = describe_spec(&OutputSpec {
1349 format: Some("text".to_string()),
1350 ..OutputSpec::default()
1351 });
1352 assert!(
1353 described.contains("Where anything else you were told"),
1354 "{described}"
1355 );
1356 }
1357
1358 #[test]
1361 fn an_unconstrained_spec_claims_nothing() {
1362 assert!(!describe_spec(&OutputSpec::default()).contains("follow this"));
1363 }
1364
1365 #[test]
1366 fn a_spec_round_trips_through_serde() {
1367 let original = spec(Some("a2ui"), Some(json!({"type": "object"})));
1368 let text = serde_json::to_string(&original).expect("a spec serializes");
1369 let back: OutputSpec = serde_json::from_str(&text).expect("and deserializes");
1370 assert_eq!(back, original);
1371 assert!(!text.contains("instructions"));
1373 assert!(!text.contains("on_validator_error"));
1374 }
1375
1376 #[test]
1380 fn the_error_policy_uses_the_manifest_spelling_on_the_wire() {
1381 let original = OutputSpec {
1382 on_validator_error: Some(OnValidatorError::Accept),
1383 ..OutputSpec::default()
1384 };
1385 let text = serde_json::to_string(&original).expect("a spec serializes");
1386 assert!(text.contains(r#""on_validator_error":"accept""#), "{text}");
1387 let back: OutputSpec = serde_json::from_str(&text).expect("and deserializes");
1388 assert_eq!(back, original);
1389
1390 let rejected = serde_json::from_str::<OutputSpec>(r#"{"on_validator_error":"sometimes"}"#);
1391 assert!(rejected.is_err(), "an unknown policy must not deserialize");
1392 }
1393
1394 #[test]
1395 fn a_final_output_round_trips_through_serde() {
1396 let original = FinalOutput::new("answer", None, "wrap".into(), 1);
1397 let text = serde_json::to_string(&original).expect("an output serializes");
1398 let back: FinalOutput = serde_json::from_str(&text).expect("and deserializes");
1399 assert_eq!(back, original);
1400 }
1401}