1use serde::{Deserialize, Serialize};
33
34pub const MAX_FINAL_OUTPUT_BYTES: usize = 256 * 1024;
42
43#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum OnValidatorError {
53 #[default]
58 Reject,
59 Accept,
63}
64
65#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
74pub struct OutputSpec {
75 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub format: Option<String>,
82
83 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub instructions: Option<String>,
88
89 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub example: Option<String>,
93
94 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub schema: Option<serde_json::Value>,
105
106 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub validator: Option<String>,
118
119 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub on_validator_error: Option<OnValidatorError>,
125}
126
127impl OutputSpec {
128 pub fn is_empty(&self) -> bool {
131 self.format.is_none()
132 && self.instructions.is_none()
133 && self.example.is_none()
134 && self.schema.is_none()
135 && self.validator.is_none()
136 && self.on_validator_error.is_none()
137 }
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151pub struct FinalOutput {
152 pub content: String,
154
155 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub format: Option<String>,
159
160 pub stage: String,
163
164 pub submitted_at: i64,
166
167 #[serde(default)]
169 pub truncated: bool,
170
171 #[serde(default, skip_serializing_if = "Vec::is_empty")]
179 pub artifacts: Vec<String>,
180}
181
182impl FinalOutput {
183 pub fn new(content: &str, format: Option<String>, stage: String, submitted_at: i64) -> Self {
190 let truncated = content.len() > MAX_FINAL_OUTPUT_BYTES;
191 let kept = crate::text::truncate_at_boundary(content, MAX_FINAL_OUTPUT_BYTES);
192 Self {
193 content: kept.to_string(),
194 format,
195 stage,
196 submitted_at,
197 truncated,
198 artifacts: Vec::new(),
199 }
200 }
201
202 pub fn with_artifacts(mut self, artifacts: Vec<String>) -> Self {
204 self.artifacts = artifacts;
205 self
206 }
207
208 pub fn descriptor(&self) -> FinalOutputDescriptor {
210 FinalOutputDescriptor {
211 format: self.format.clone(),
212 stage: self.stage.clone(),
213 submitted_at: self.submitted_at,
214 bytes: self.content.len(),
215 truncated: self.truncated,
216 artifacts: self.artifacts.clone(),
217 }
218 }
219}
220
221#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
230pub struct FinalOutputDescriptor {
231 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub format: Option<String>,
234 pub stage: String,
236 pub submitted_at: i64,
238 #[serde(default)]
240 pub bytes: usize,
241 #[serde(default)]
243 pub truncated: bool,
244 #[serde(default, skip_serializing_if = "Vec::is_empty")]
246 pub artifacts: Vec<String>,
247}
248
249pub const FINAL_OUTPUT_FILE: &str = "final_output";
254
255pub fn resolve_output_spec(
271 agent: Option<&OutputSpec>,
272 stage: Option<&OutputSpec>,
273 request: Option<&OutputSpec>,
274) -> Option<OutputSpec> {
275 if agent.is_none() && stage.is_none() && request.is_none() {
276 return None;
277 }
278
279 fn field<T: Clone>(
280 agent: Option<&OutputSpec>,
281 stage: Option<&OutputSpec>,
282 request: Option<&OutputSpec>,
283 get: impl Fn(&OutputSpec) -> Option<T>,
284 ) -> Option<T> {
285 request
286 .and_then(&get)
287 .or_else(|| stage.and_then(&get))
288 .or_else(|| agent.and_then(&get))
289 }
290
291 let declared_format = field(agent, stage, None, |s| s.format.clone());
296 let requested_format = request.and_then(|r| r.format.clone());
297 let reshaped = requested_format.is_some() && requested_format != declared_format;
298
299 let shape_field = |get: fn(&OutputSpec) -> Option<serde_json::Value>| match reshaped {
300 true => request.and_then(get),
301 false => field(agent, stage, request, get),
302 };
303 let validator = match reshaped {
304 true => request.and_then(|r| r.validator.clone()),
305 false => field(agent, stage, request, |s| s.validator.clone()),
306 };
307 let on_validator_error = match reshaped {
310 true => request.and_then(|r| r.on_validator_error),
311 false => field(agent, stage, request, |s| s.on_validator_error),
312 };
313
314 Some(OutputSpec {
315 format: field(agent, stage, request, |s| s.format.clone()),
316 instructions: field(agent, stage, request, |s| s.instructions.clone()),
317 example: field(agent, stage, request, |s| s.example.clone()),
318 schema: shape_field(|s| s.schema.clone()),
319 validator,
320 on_validator_error,
321 })
322}
323
324pub fn retired_check_warnings(
343 blueprint: &crate::Blueprint,
344 request: Option<&OutputSpec>,
345) -> Vec<String> {
346 let Some(requested) = request.and_then(|r| r.format.as_deref()) else {
347 return Vec::new();
348 };
349 let request_has_schema = request.is_some_and(|r| r.schema.is_some());
350 let mut groups: Vec<(RetiredChecks, Vec<String>)> = Vec::new();
351 for stage in &blueprint.stages {
352 let Some(retired) = retired_checks_for_stage(
353 blueprint.output.as_ref(),
354 stage.output.as_ref(),
355 requested,
356 request_has_schema,
357 ) else {
358 continue;
359 };
360 match groups.iter_mut().find(|(g, _)| *g == retired) {
361 Some((_, stages)) => stages.push(stage.name.clone()),
362 None => groups.push((retired, vec![stage.name.clone()])),
363 }
364 }
365 groups
366 .iter()
367 .map(|(checks, stages)| checks.warning_line(requested, stages, request_has_schema))
368 .collect()
369}
370
371#[derive(PartialEq, Eq)]
374struct RetiredChecks {
375 declared_format: Option<String>,
377 validator: Option<String>,
379 schema: bool,
381}
382
383fn retired_checks_for_stage(
389 agent: Option<&OutputSpec>,
390 stage: Option<&OutputSpec>,
391 requested: &str,
392 request_has_schema: bool,
393) -> Option<RetiredChecks> {
394 let declared =
395 |get: fn(&OutputSpec) -> Option<&str>| stage.and_then(get).or_else(|| agent.and_then(get));
396 let declared_format = declared(|s| s.format.as_deref());
397 if declared_format == Some(requested) {
398 return None;
399 }
400 let validator = declared(|s| s.validator.as_deref());
401 let schema = !request_has_schema
402 && stage
403 .and_then(|s| s.schema.as_ref())
404 .or_else(|| agent.and_then(|a| a.schema.as_ref()))
405 .is_some();
406 if validator.is_none() && !schema {
407 return None;
408 }
409 Some(RetiredChecks {
410 declared_format: declared_format.map(str::to_string),
411 validator: validator.map(str::to_string),
412 schema,
413 })
414}
415
416impl RetiredChecks {
417 fn warning_line(&self, requested: &str, stages: &[String], request_has_schema: bool) -> String {
422 let cause = match &self.declared_format {
423 Some(declared) => format!(
424 "requested output format '{requested}' differs from the declared '{declared}'"
425 ),
426 None => format!(
427 "requested output format '{requested}' reshapes an output declared without a \
428 format"
429 ),
430 };
431 let what = match (&self.validator, self.schema) {
432 (Some(v), true) => format!("the Rhai validator '{v}' and the JSON schema"),
433 (Some(v), false) => format!("the Rhai validator '{v}'"),
434 (None, _) => "the JSON schema".to_string(),
435 };
436 let tail = match request_has_schema {
437 true => "the schema supplied with the request is what checks the answer now",
438 false => {
439 "nothing checks the answer's shape; supply a schema with the request if the new \
440 shape needs one"
441 }
442 };
443 format!(
444 "{cause}: {what} declared for {} will not run, because a check written for one shape \
445 cannot judge another. Instead, {tail}.",
446 stage_phrase(stages)
447 )
448 }
449}
450
451fn stage_phrase(stages: &[String]) -> String {
454 let quoted: Vec<String> = stages.iter().map(|s| format!("'{s}'")).collect();
455 match quoted.split_last() {
456 Some((last, [])) => format!("stage {last}"),
457 Some((last, [first])) => format!("stages {first} and {last}"),
458 Some((last, head)) => format!("stages {}, and {last}", head.join(", ")),
459 None => "its stages".to_string(),
462 }
463}
464
465pub fn describe_spec(spec: &OutputSpec) -> String {
485 let mut parts = Vec::new();
486 if let Some(format) = &spec.format {
487 parts.push(format!("Return it in this format: {format}."));
488 }
489 if let Some(instructions) = &spec.instructions {
490 parts.push(instructions.clone());
491 }
492 if let Some(schema) = &spec.schema {
493 parts.push(format!(
494 "It must be JSON valid against this schema:\n{schema}"
495 ));
496 }
497 if let Some(example) = &spec.example {
498 parts.push(format!(
499 "Here is an example of the expected shape:\n{example}"
500 ));
501 }
502 if !parts.is_empty() {
503 parts.push(
504 "This governs how the answer is presented. Where anything else you were told says \
505 to present it differently - its length, its structure, what to lead with - follow \
506 this."
507 .to_string(),
508 );
509 }
510 parts.join("\n\n")
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516 use serde_json::json;
517
518 fn spec(format: Option<&str>, schema: Option<serde_json::Value>) -> OutputSpec {
519 OutputSpec {
520 format: format.map(str::to_string),
521 schema,
522 ..OutputSpec::default()
523 }
524 }
525
526 #[test]
531 fn artifacts_attach_to_a_submission_and_reach_the_descriptor() {
532 let output = FinalOutput::new(
533 "the summary",
534 Some("markdown".to_string()),
535 "present".to_string(),
536 42,
537 )
538 .with_artifacts(vec![
539 "data/dataset.csv".to_string(),
540 "report.pdf".to_string(),
541 ]);
542
543 assert_eq!(output.artifacts, ["data/dataset.csv", "report.pdf"]);
544 assert_eq!(output.descriptor().artifacts, output.artifacts);
545 assert_eq!(output.descriptor().bytes, "the summary".len());
548 }
549
550 #[test]
551 fn a_submission_carries_no_artifacts_unless_given_some() {
552 assert!(
553 FinalOutput::new("x", None, "present".to_string(), 0)
554 .artifacts
555 .is_empty()
556 );
557 }
558
559 #[test]
560 fn empty_spec_constrains_nothing() {
561 assert!(OutputSpec::default().is_empty());
562 assert!(!spec(Some("json"), None).is_empty());
563 assert!(!spec(None, Some(json!({}))).is_empty());
564 assert!(
565 !OutputSpec {
566 instructions: Some("be brief".to_string()),
567 ..OutputSpec::default()
568 }
569 .is_empty()
570 );
571 assert!(
572 !OutputSpec {
573 example: Some("<doc/>".to_string()),
574 ..OutputSpec::default()
575 }
576 .is_empty()
577 );
578 assert!(
579 !OutputSpec {
580 on_validator_error: Some(OnValidatorError::Accept),
581 ..OutputSpec::default()
582 }
583 .is_empty()
584 );
585 }
586
587 #[test]
588 fn no_level_asking_for_output_resolves_to_none() {
589 assert_eq!(resolve_output_spec(None, None, None), None);
590 }
591
592 #[test]
593 fn later_levels_win_field_by_field() {
594 let agent = OutputSpec {
595 format: Some("markdown".to_string()),
596 instructions: Some("agent guidance".to_string()),
597 example: Some("agent example".to_string()),
598 schema: None,
599 validator: None,
600 on_validator_error: None,
601 };
602 let stage = OutputSpec {
603 instructions: Some("stage guidance".to_string()),
604 ..OutputSpec::default()
605 };
606 let resolved = resolve_output_spec(Some(&agent), Some(&stage), None)
607 .expect("some level asked for an output");
608 assert_eq!(resolved.instructions.as_deref(), Some("stage guidance"));
610 assert_eq!(resolved.format.as_deref(), Some("markdown"));
611 assert_eq!(resolved.example.as_deref(), Some("agent example"));
612 }
613
614 #[test]
615 fn a_stage_alone_can_ask_for_an_output() {
616 let stage = spec(Some("a2ui"), None);
617 let resolved =
618 resolve_output_spec(None, Some(&stage), None).expect("the stage asked for one");
619 assert_eq!(resolved.format.as_deref(), Some("a2ui"));
620 }
621
622 #[test]
626 fn re_stating_the_declared_format_keeps_its_shape_checks() {
627 let agent = OutputSpec {
628 format: Some("json".to_string()),
629 schema: Some(json!({"type": "object"})),
630 validator: Some("v.rhai".to_string()),
631 ..OutputSpec::default()
632 };
633 let request = spec(Some("json"), None);
634 let resolved = resolve_output_spec(Some(&agent), None, Some(&request))
635 .expect("the agent asked for one");
636 assert_eq!(resolved.schema, Some(json!({"type": "object"})));
637 assert_eq!(resolved.validator.as_deref(), Some("v.rhai"));
638 }
639
640 #[test]
644 fn reshaping_retires_the_validator_too() {
645 let agent = OutputSpec {
646 format: Some("a2ui".to_string()),
647 validator: Some("a2ui.rhai".to_string()),
648 on_validator_error: Some(OnValidatorError::Accept),
649 ..OutputSpec::default()
650 };
651 let request = spec(Some("xml"), None);
652 let resolved = resolve_output_spec(Some(&agent), None, Some(&request))
653 .expect("the agent asked for one");
654 assert_eq!(resolved.format.as_deref(), Some("xml"));
655 assert_eq!(resolved.validator, None);
656 assert_eq!(resolved.on_validator_error, None);
657 }
658
659 #[test]
662 fn the_stage_error_policy_overrides_the_agents() {
663 let agent = OutputSpec {
664 format: Some("a2ui".to_string()),
665 validator: Some("a2ui.rhai".to_string()),
666 on_validator_error: Some(OnValidatorError::Reject),
667 ..OutputSpec::default()
668 };
669 let stage = OutputSpec {
670 on_validator_error: Some(OnValidatorError::Accept),
671 ..OutputSpec::default()
672 };
673 let resolved =
674 resolve_output_spec(Some(&agent), Some(&stage), None).expect("the agent asked for one");
675 assert_eq!(resolved.on_validator_error, Some(OnValidatorError::Accept));
676 assert_eq!(
677 resolved.validator.as_deref(),
678 Some("a2ui.rhai"),
679 "the validator itself still falls through from the agent"
680 );
681 }
682
683 #[test]
686 fn a_reshaping_caller_can_supply_their_own_error_policy() {
687 let agent = OutputSpec {
688 format: Some("a2ui".to_string()),
689 validator: Some("a2ui.rhai".to_string()),
690 ..OutputSpec::default()
691 };
692 let request = OutputSpec {
693 format: Some("xml".to_string()),
694 validator: Some("xml.rhai".to_string()),
695 on_validator_error: Some(OnValidatorError::Accept),
696 ..OutputSpec::default()
697 };
698 let resolved = resolve_output_spec(Some(&agent), None, Some(&request))
699 .expect("the agent asked for one");
700 assert_eq!(resolved.validator.as_deref(), Some("xml.rhai"));
701 assert_eq!(resolved.on_validator_error, Some(OnValidatorError::Accept));
702 }
703
704 #[test]
706 fn a_caller_can_supply_shape_checks_with_its_own_format() {
707 let agent = OutputSpec {
708 format: Some("a2ui".to_string()),
709 validator: Some("a2ui.rhai".to_string()),
710 ..OutputSpec::default()
711 };
712 let request = OutputSpec {
713 format: Some("json".to_string()),
714 schema: Some(json!({"type": "array"})),
715 ..OutputSpec::default()
716 };
717 let resolved = resolve_output_spec(Some(&agent), None, Some(&request))
718 .expect("the agent asked for one");
719 assert_eq!(resolved.schema, Some(json!({"type": "array"})));
720 assert_eq!(resolved.validator, None, "the agent's own is still retired");
721 }
722
723 #[test]
724 fn a_caller_reshaping_the_output_drops_the_declared_schema() {
725 let agent = spec(Some("json"), Some(json!({"type": "object"})));
726 let request = spec(Some("a2ui"), None);
729 let resolved = resolve_output_spec(Some(&agent), None, Some(&request))
730 .expect("the agent asked for one");
731 assert_eq!(resolved.format.as_deref(), Some("a2ui"));
732 assert_eq!(resolved.schema, None);
733 }
734
735 #[test]
736 fn a_caller_supplying_its_own_schema_keeps_it() {
737 let agent = spec(Some("json"), Some(json!({"type": "object"})));
738 let request = spec(Some("json"), Some(json!({"type": "array"})));
739 let resolved = resolve_output_spec(Some(&agent), None, Some(&request))
740 .expect("the agent asked for one");
741 assert_eq!(resolved.schema, Some(json!({"type": "array"})));
742 }
743
744 #[test]
745 fn a_caller_that_names_no_format_leaves_the_schema_alone() {
746 let agent = spec(Some("json"), Some(json!({"type": "object"})));
747 let request = OutputSpec {
749 instructions: Some("keep it short".to_string()),
750 ..OutputSpec::default()
751 };
752 let resolved = resolve_output_spec(Some(&agent), None, Some(&request))
753 .expect("the agent asked for one");
754 assert_eq!(resolved.format.as_deref(), Some("json"));
755 assert_eq!(resolved.schema, Some(json!({"type": "object"})));
756 }
757
758 fn blueprint_with(agent: Option<&str>, stages: &[(&str, Option<&str>)]) -> crate::Blueprint {
762 let mut manifest =
763 String::from("[agent]\nname = \"checked\"\nversion = \"1.0.0\"\ndescription = \"d\"\n");
764 if let Some(fields) = agent {
765 manifest.push_str(&format!("\n[agent.output]\n{fields}\n"));
766 }
767 for (name, output) in stages {
768 manifest.push_str(&format!("\n[stages.{name}]\nsystem_prompt = \"p\"\n"));
769 if let Some(fields) = output {
770 manifest.push_str(&format!("\n[stages.{name}.output]\n{fields}\n"));
771 }
772 }
773 crate::manifest::parse_manifest(&manifest).expect("the test manifest parses")
774 }
775
776 #[test]
779 fn a_differing_format_earns_a_warning_naming_the_retired_validator() {
780 let bp = blueprint_with(
781 Some("format = \"markdown\"\nvalidator = \"checks/report.rhai\""),
782 &[("plan", None)],
783 );
784 let request = spec(Some("json"), None);
785 let warnings = retired_check_warnings(&bp, Some(&request));
786 assert_eq!(warnings.len(), 1, "{warnings:?}");
787 let line = &warnings[0];
788 assert!(line.contains("'json'"), "{line}");
789 assert!(line.contains("'markdown'"), "{line}");
790 assert!(
791 line.contains("the Rhai validator 'checks/report.rhai'"),
792 "{line}"
793 );
794 assert!(line.contains("stage 'plan'"), "{line}");
795 }
796
797 #[test]
802 fn nothing_retired_means_nothing_warned() {
803 let bp = blueprint_with(
804 Some("format = \"markdown\"\nvalidator = \"v.rhai\"\nschema = { type = \"object\" }"),
805 &[("plan", None)],
806 );
807 let restated = spec(Some("markdown"), None);
808 assert!(retired_check_warnings(&bp, Some(&restated)).is_empty());
809 let formatless = OutputSpec {
810 instructions: Some("keep it short".to_string()),
811 ..OutputSpec::default()
812 };
813 assert!(retired_check_warnings(&bp, Some(&formatless)).is_empty());
814 assert!(retired_check_warnings(&bp, None).is_empty());
815 let unchecked = blueprint_with(Some("format = \"markdown\""), &[("plan", None)]);
817 let reshaped = spec(Some("json"), None);
818 assert!(retired_check_warnings(&unchecked, Some(&reshaped)).is_empty());
819 }
820
821 #[test]
824 fn the_warning_names_exactly_what_is_lost() {
825 let request = spec(Some("json"), None);
826 let schema_only = blueprint_with(
827 Some("format = \"markdown\"\nschema = { type = \"object\" }"),
828 &[("plan", None)],
829 );
830 let warnings = retired_check_warnings(&schema_only, Some(&request));
831 assert!(
832 warnings[0].contains("the JSON schema declared"),
833 "{warnings:?}"
834 );
835 let both = blueprint_with(
836 Some("format = \"markdown\"\nvalidator = \"v.rhai\"\nschema = { type = \"object\" }"),
837 &[("plan", None)],
838 );
839 let warnings = retired_check_warnings(&both, Some(&request));
840 assert!(
841 warnings[0].contains("the Rhai validator 'v.rhai' and the JSON schema"),
842 "{warnings:?}"
843 );
844 }
845
846 #[test]
850 fn a_replacement_schema_is_not_warned_about() {
851 let bp = blueprint_with(
852 Some("format = \"markdown\"\nvalidator = \"v.rhai\"\nschema = { type = \"object\" }"),
853 &[("plan", None)],
854 );
855 let request = spec(Some("json"), Some(json!({"type": "array"})));
856 let warnings = retired_check_warnings(&bp, Some(&request));
857 assert_eq!(warnings.len(), 1, "{warnings:?}");
858 assert!(
859 warnings[0].contains("the Rhai validator 'v.rhai'"),
860 "{warnings:?}"
861 );
862 assert!(
863 !warnings[0].contains("JSON schema declared"),
864 "{warnings:?}"
865 );
866 assert!(
869 warnings[0].contains("the schema supplied with the request"),
870 "{warnings:?}"
871 );
872 let schema_only = blueprint_with(
875 Some("format = \"markdown\"\nschema = { type = \"object\" }"),
876 &[("plan", None)],
877 );
878 assert!(retired_check_warnings(&schema_only, Some(&request)).is_empty());
879 }
880
881 #[test]
884 fn stages_losing_the_same_checks_share_one_line() {
885 let bp = blueprint_with(
886 Some("format = \"markdown\"\nvalidator = \"shared.rhai\""),
887 &[
888 ("plan", None),
889 ("draft", None),
890 ("wrap", Some("format = \"a2ui\"\nvalidator = \"a2ui.rhai\"")),
891 ],
892 );
893 let request = spec(Some("json"), None);
894 let warnings = retired_check_warnings(&bp, Some(&request));
895 assert_eq!(warnings.len(), 2, "{warnings:?}");
896 assert!(
897 warnings[0].contains("stages 'plan' and 'draft'"),
898 "{warnings:?}"
899 );
900 assert!(warnings[1].contains("stage 'wrap'"), "{warnings:?}");
901 assert!(warnings[1].contains("'a2ui'"), "{warnings:?}");
902 }
903
904 #[test]
907 fn a_stage_level_declaration_retires_without_an_agent_one() {
908 let bp = blueprint_with(
909 None,
910 &[(
911 "plan",
912 Some("format = \"markdown\"\nvalidator = \"v.rhai\""),
913 )],
914 );
915 let request = spec(Some("json"), None);
916 let warnings = retired_check_warnings(&bp, Some(&request));
917 assert_eq!(warnings.len(), 1, "{warnings:?}");
918 assert!(
919 warnings[0].contains("the Rhai validator 'v.rhai'"),
920 "{warnings:?}"
921 );
922 }
923
924 #[test]
927 fn a_stage_already_in_the_requested_format_keeps_its_checks() {
928 let bp = blueprint_with(
929 Some("format = \"markdown\"\nvalidator = \"shared.rhai\""),
930 &[("plan", None), ("emit", Some("format = \"json\""))],
931 );
932 let request = spec(Some("json"), None);
933 let warnings = retired_check_warnings(&bp, Some(&request));
934 assert_eq!(warnings.len(), 1, "{warnings:?}");
935 assert!(warnings[0].contains("stage 'plan'"), "{warnings:?}");
936 }
937
938 #[test]
942 fn a_formatless_declaration_is_reshaped_by_any_request() {
943 let bp = blueprint_with(Some("validator = \"v.rhai\""), &[("plan", None)]);
944 let request = spec(Some("json"), None);
945 let warnings = retired_check_warnings(&bp, Some(&request));
946 assert_eq!(warnings.len(), 1, "{warnings:?}");
947 assert!(
948 warnings[0].contains("declared without a format"),
949 "{warnings:?}"
950 );
951 }
952
953 #[test]
955 fn stage_phrases_read_as_prose() {
956 let names = |names: &[&str]| names.iter().map(|n| n.to_string()).collect::<Vec<_>>();
957 assert_eq!(stage_phrase(&names(&["a"])), "stage 'a'");
958 assert_eq!(stage_phrase(&names(&["a", "b"])), "stages 'a' and 'b'");
959 assert_eq!(
960 stage_phrase(&names(&["a", "b", "c"])),
961 "stages 'a', 'b', and 'c'"
962 );
963 assert_eq!(stage_phrase(&[]), "its stages");
964 }
965
966 #[test]
967 fn short_content_is_stored_verbatim() {
968 let out = FinalOutput::new(
969 "done: 3 files",
970 Some("markdown".to_string()),
971 "wrap".into(),
972 7,
973 );
974 assert_eq!(out.content, "done: 3 files");
975 assert_eq!(out.format.as_deref(), Some("markdown"));
976 assert_eq!(out.stage, "wrap");
977 assert_eq!(out.submitted_at, 7);
978 assert!(!out.truncated);
979 }
980
981 #[test]
982 fn oversized_content_is_cut_at_a_char_boundary_and_flagged() {
983 let mut content = "a".repeat(MAX_FINAL_OUTPUT_BYTES - 1);
986 content.push('\u{1f600}');
987 let out = FinalOutput::new(&content, None, "wrap".into(), 0);
988 assert!(out.truncated);
989 assert_eq!(out.content.len(), MAX_FINAL_OUTPUT_BYTES - 1);
990 assert!(out.format.is_none());
991 }
992
993 #[test]
994 fn describe_spec_is_empty_when_nothing_is_constrained() {
995 assert_eq!(describe_spec(&OutputSpec::default()), "");
996 }
997
998 #[test]
999 fn describe_spec_renders_every_field_it_has() {
1000 let described = describe_spec(&OutputSpec {
1001 format: Some("a2ui".to_string()),
1002 instructions: Some("One card per finding.".to_string()),
1003 example: Some("{\"root\": {}}".to_string()),
1004 schema: Some(json!({"type": "object"})),
1005 validator: None,
1006 on_validator_error: None,
1007 });
1008 assert!(described.contains("Return it in this format: a2ui."));
1009 assert!(described.contains("One card per finding."));
1010 assert!(described.contains("valid against this schema"));
1011 assert!(described.contains("{\"root\": {}}"));
1012 }
1013
1014 #[test]
1018 fn a_constrained_spec_says_it_outranks_the_stage_prompt() {
1019 let described = describe_spec(&OutputSpec {
1020 instructions: Some("Reply with only the integer.".to_string()),
1021 ..OutputSpec::default()
1022 });
1023 assert!(
1024 described.contains("Where anything else you were told"),
1025 "{described}"
1026 );
1027 assert!(
1030 described.trim_end().ends_with("follow this."),
1031 "{described}"
1032 );
1033 }
1034
1035 #[test]
1038 fn a_format_only_spec_claims_precedence_too() {
1039 let described = describe_spec(&OutputSpec {
1040 format: Some("text".to_string()),
1041 ..OutputSpec::default()
1042 });
1043 assert!(
1044 described.contains("Where anything else you were told"),
1045 "{described}"
1046 );
1047 }
1048
1049 #[test]
1052 fn an_unconstrained_spec_claims_nothing() {
1053 assert!(!describe_spec(&OutputSpec::default()).contains("follow this"));
1054 }
1055
1056 #[test]
1057 fn a_spec_round_trips_through_serde() {
1058 let original = spec(Some("a2ui"), Some(json!({"type": "object"})));
1059 let text = serde_json::to_string(&original).expect("a spec serializes");
1060 let back: OutputSpec = serde_json::from_str(&text).expect("and deserializes");
1061 assert_eq!(back, original);
1062 assert!(!text.contains("instructions"));
1064 assert!(!text.contains("on_validator_error"));
1065 }
1066
1067 #[test]
1071 fn the_error_policy_uses_the_manifest_spelling_on_the_wire() {
1072 let original = OutputSpec {
1073 on_validator_error: Some(OnValidatorError::Accept),
1074 ..OutputSpec::default()
1075 };
1076 let text = serde_json::to_string(&original).expect("a spec serializes");
1077 assert!(text.contains(r#""on_validator_error":"accept""#), "{text}");
1078 let back: OutputSpec = serde_json::from_str(&text).expect("and deserializes");
1079 assert_eq!(back, original);
1080
1081 let rejected = serde_json::from_str::<OutputSpec>(r#"{"on_validator_error":"sometimes"}"#);
1082 assert!(rejected.is_err(), "an unknown policy must not deserialize");
1083 }
1084
1085 #[test]
1086 fn a_final_output_round_trips_through_serde() {
1087 let original = FinalOutput::new("answer", None, "wrap".into(), 1);
1088 let text = serde_json::to_string(&original).expect("an output serializes");
1089 let back: FinalOutput = serde_json::from_str(&text).expect("and deserializes");
1090 assert_eq!(back, original);
1091 }
1092}