Skip to main content

leviath_core/
output.rs

1//! An agent's final output: the one value a run hands back to whoever asked.
2//!
3//! Before this existed, the only way an agent could return something was to
4//! write a file. Every surface that should have reported a result reported
5//! something else - `GET /api/agents/{id}/result` tailed a log file, the
6//! completion webhook's `result` field carried the *error* string, and
7//! `wait_for_agent`, whose schema promises "return its final result", returned
8//! `"Sub-agent 'x' finished with status: Complete"`. A fan-out worker's
9//! contribution to its merge stage was whatever text happened to sit in its last
10//! assistant message, so a worker whose final turn was a tool call contributed
11//! an empty string.
12//!
13//! # The format rule
14//!
15//! **Nothing here interprets the format.** There is no enum of supported
16//! formats, no per-format parser, and no branch on a format name anywhere in the
17//! engine. [`OutputSpec::format`] is an opaque label; markdown, JSON, XML, CSV,
18//! an [a2ui](https://a2ui.org/) document, and a house format invented next week
19//! all travel the same path: describe it to the model, record what comes back
20//! verbatim, hand it on unchanged.
21//!
22//! The single exception is opt-in and named as such. When an author supplies
23//! [`OutputSpec::schema`], the submission is parsed as JSON and validated
24//! against it. That is the only thing that ever looks inside the content, and it
25//! happens because someone asked for it, never because a format string said
26//! `"json"`.
27//!
28//! This is also why an unusual format needs no engine support. There is no
29//! usual: every format is produced by the model from
30//! [`OutputSpec::instructions`] and [`OutputSpec::example`].
31
32use serde::{Deserialize, Serialize};
33
34/// Largest final output kept, in bytes. Anything longer is cut at a character
35/// boundary and flagged [`FinalOutput::truncated`].
36///
37/// Sits between the log tail the result endpoint already serves (64 KiB) and the
38/// cap on reading a file the run wrote (1 MiB). A final output is meant to be an
39/// answer, not a payload; an agent with megabytes to hand back should write a
40/// file and say where it is.
41pub const MAX_FINAL_OUTPUT_BYTES: usize = 256 * 1024;
42
43/// What shape an agent should return.
44///
45/// Declared by a blueprint (`[agent.output]`), narrowed by a stage
46/// (`[stages.<name>.output]`), and overridable by whoever starts the run. See
47/// [`resolve_output_spec`] for how the three combine.
48///
49/// Every field is optional, and an entirely empty spec is meaningful: it asks
50/// for a final output without constraining its shape.
51#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
52pub struct OutputSpec {
53    /// An opaque label for the shape, carried to the model and recorded beside
54    /// the result. `"markdown"`, `"json"`, `"a2ui"`, and
55    /// `"application/vnd.acme.report+xml"` are all equally valid and equally
56    /// uninterpreted. Consumers that render differently per format (a browser
57    /// UI, say) match on this string; the engine never does.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub format: Option<String>,
60
61    /// Free-form guidance folded into the `submit_output` tool description and
62    /// the output stage's system prompt. This is where a format that the model
63    /// has never seen gets explained.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub instructions: Option<String>,
66
67    /// A literal sample shown to the model verbatim. The most effective lever
68    /// for an unusual format, and the reason one needs no code support.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub example: Option<String>,
71
72    /// A JSON Schema describing the answer's shape. When present, a submission
73    /// is parsed as JSON and validated against it, and a failure is refused back
74    /// to the model so it can correct itself.
75    ///
76    /// Separate from `format` because they answer different questions.
77    /// `format = "json"` asks "does this parse as JSON"; a schema asks "does the
78    /// parsed document have the fields I need". A format check comes free for
79    /// the handful of formats the engine can parse; shape is only ever checked
80    /// when someone writes a schema down.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub schema: Option<serde_json::Value>,
83
84    /// A `.rhai` script that decides whether an answer is valid, as a path
85    /// relative to the blueprint directory.
86    ///
87    /// For a format the engine cannot parse and a shape a JSON Schema cannot
88    /// describe. The script defines `fn validate(content)` and returns `()` when
89    /// the answer is fine or a string saying what is wrong; the string goes back
90    /// to the agent as the same refusal a schema failure produces.
91    ///
92    /// Written for the format it accompanies, so a caller who overrides the
93    /// format retires it along with the schema.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub validator: Option<String>,
96}
97
98impl OutputSpec {
99    /// Whether this spec constrains anything at all. An empty spec still asks
100    /// for an output, so this is about wording the request, not skipping it.
101    pub fn is_empty(&self) -> bool {
102        self.format.is_none()
103            && self.instructions.is_none()
104            && self.example.is_none()
105            && self.schema.is_none()
106            && self.validator.is_none()
107    }
108}
109
110/// What an agent actually produced, content included.
111///
112/// [`content`](Self::content) is stored exactly as submitted. Nothing in the
113/// engine reformats, re-indents, or re-serializes it, so a consumer that asked
114/// for a particular byte sequence receives that byte sequence.
115///
116/// This is the in-memory and one-shot form: the live ECS component, the
117/// completion event, a webhook body, a reply to a waiting parent. What a run's
118/// `meta.json` carries is the [`FinalOutputDescriptor`], because that file is
119/// read for every run on every listing and must not carry a payload.
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct FinalOutput {
122    /// The submission, verbatim (subject only to [`MAX_FINAL_OUTPUT_BYTES`]).
123    pub content: String,
124
125    /// The format label in effect when this was submitted, if any. Copied from
126    /// the resolved spec rather than guessed from the content.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub format: Option<String>,
129
130    /// The stage that produced it. Read by the enforcement gate, which must
131    /// tell "this stage submitted" from "some earlier stage did".
132    pub stage: String,
133
134    /// Unix seconds at submission.
135    pub submitted_at: i64,
136
137    /// Whether [`MAX_FINAL_OUTPUT_BYTES`] cut the content short.
138    #[serde(default)]
139    pub truncated: bool,
140
141    /// Files the run produced, as workdir-relative paths.
142    ///
143    /// An answer is one model response; anything larger is a file. A run that
144    /// gathers two million rows writes them incrementally and names the file
145    /// here, so a consumer can fetch it rather than parse the path out of prose.
146    /// Validated to resolve inside the run's working directory, the same rule
147    /// the files endpoint enforces when serving one.
148    #[serde(default, skip_serializing_if = "Vec::is_empty")]
149    pub artifacts: Vec<String>,
150}
151
152impl FinalOutput {
153    /// Record a submission, truncating at a character boundary if it exceeds
154    /// [`MAX_FINAL_OUTPUT_BYTES`].
155    ///
156    /// Truncation walks back to a boundary rather than slicing by byte index:
157    /// this workspace denies `clippy::string_slice` because a byte cut through a
158    /// multi-byte character once double-panicked and aborted the whole daemon.
159    pub fn new(content: &str, format: Option<String>, stage: String, submitted_at: i64) -> Self {
160        let truncated = content.len() > MAX_FINAL_OUTPUT_BYTES;
161        let kept = crate::text::truncate_at_boundary(content, MAX_FINAL_OUTPUT_BYTES);
162        Self {
163            content: kept.to_string(),
164            format,
165            stage,
166            submitted_at,
167            truncated,
168            artifacts: Vec::new(),
169        }
170    }
171
172    /// The same submission with `artifacts` attached.
173    pub fn with_artifacts(mut self, artifacts: Vec<String>) -> Self {
174        self.artifacts = artifacts;
175        self
176    }
177
178    /// Everything about this answer except the bytes.
179    pub fn descriptor(&self) -> FinalOutputDescriptor {
180        FinalOutputDescriptor {
181            format: self.format.clone(),
182            stage: self.stage.clone(),
183            submitted_at: self.submitted_at,
184            bytes: self.content.len(),
185            truncated: self.truncated,
186            artifacts: self.artifacts.clone(),
187        }
188    }
189}
190
191/// What a run's `meta.json` records about its answer: everything but the bytes.
192///
193/// The content lives beside it in a sidecar file
194/// ([`FINAL_OUTPUT_FILE`]). `meta.json` is
195/// parsed for every run on every `lev ps`, every `/api/runs` page, and every
196/// restart scan, so a payload in it is paid for by operations that never wanted
197/// it: a thousand answered runs would mean hundreds of megabytes of JSON per
198/// listing. A descriptor is a couple of hundred bytes and stays that way.
199#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
200pub struct FinalOutputDescriptor {
201    /// The format label the answer was produced under, if any.
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub format: Option<String>,
204    /// The stage that produced it.
205    pub stage: String,
206    /// Unix seconds at submission.
207    pub submitted_at: i64,
208    /// Size of the answer in bytes, so a caller can decide whether to fetch it.
209    #[serde(default)]
210    pub bytes: usize,
211    /// Whether [`MAX_FINAL_OUTPUT_BYTES`] cut the answer short.
212    #[serde(default)]
213    pub truncated: bool,
214    /// Files the run produced, as workdir-relative paths.
215    #[serde(default, skip_serializing_if = "Vec::is_empty")]
216    pub artifacts: Vec<String>,
217}
218
219/// The file, inside a run's directory, holding the answer's bytes.
220///
221/// Raw content with no wrapper, so serving it is a read and `lev result --raw`
222/// is a copy.
223pub const FINAL_OUTPUT_FILE: &str = "final_output";
224
225/// Combine the blueprint's, the stage's, and the caller's output specs into the
226/// one that governs a stage. Later levels win field by field, the way
227/// [`resolve_nudge`](crate::blueprint::resolve_nudge) cascades.
228///
229/// Returns `None` when no level asks for an output at all, which is how a stage
230/// that has nothing to hand back stays silent.
231///
232/// # The schema drop
233///
234/// A caller who names a `format` and supplies no `schema` **drops the declared
235/// schema**. Validating an a2ui document against the agent's own JSON schema
236/// would be nonsense: the caller asked for a different shape, so the check
237/// written for the old shape no longer applies. A caller who wants validation
238/// supplies a schema alongside the format. This is the one place where fields do
239/// not cascade independently, and it is deliberate.
240pub fn resolve_output_spec(
241    agent: Option<&OutputSpec>,
242    stage: Option<&OutputSpec>,
243    request: Option<&OutputSpec>,
244) -> Option<OutputSpec> {
245    if agent.is_none() && stage.is_none() && request.is_none() {
246        return None;
247    }
248
249    fn field<T: Clone>(
250        agent: Option<&OutputSpec>,
251        stage: Option<&OutputSpec>,
252        request: Option<&OutputSpec>,
253        get: impl Fn(&OutputSpec) -> Option<T>,
254    ) -> Option<T> {
255        request
256            .and_then(&get)
257            .or_else(|| stage.and_then(&get))
258            .or_else(|| agent.and_then(&get))
259    }
260
261    // A shape check is written for one format. When a caller asks for a
262    // different one, a check the blueprint declared no longer describes what is
263    // being produced, so it is retired rather than applied to something it was
264    // never about. A caller who wants their new shape checked supplies their own.
265    let declared_format = field(agent, stage, None, |s| s.format.clone());
266    let requested_format = request.and_then(|r| r.format.clone());
267    let reshaped = requested_format.is_some() && requested_format != declared_format;
268
269    let shape_field = |get: fn(&OutputSpec) -> Option<serde_json::Value>| match reshaped {
270        true => request.and_then(get),
271        false => field(agent, stage, request, get),
272    };
273    let validator = match reshaped {
274        true => request.and_then(|r| r.validator.clone()),
275        false => field(agent, stage, request, |s| s.validator.clone()),
276    };
277
278    Some(OutputSpec {
279        format: field(agent, stage, request, |s| s.format.clone()),
280        instructions: field(agent, stage, request, |s| s.instructions.clone()),
281        example: field(agent, stage, request, |s| s.example.clone()),
282        schema: shape_field(|s| s.schema.clone()),
283        validator,
284    })
285}
286
287/// Render a resolved spec as the guidance an agent reads.
288///
289/// Used twice for the same text: once in the `submit_output` tool description
290/// and once in an output stage's system prompt. Saying it in both places matters
291/// most for a format the model has no prior knowledge of, which is exactly the
292/// case this module is built to support.
293///
294/// A constrained spec closes with a precedence sentence, because without one
295/// this text and the stage's own system prompt are two peer instructions and
296/// which wins is model-dependent (issue #282: a stage prompt saying "lead with
297/// the diagnosis" beat `--output-instructions "reply with only the integer"` on
298/// some models and lost on others). By the time this runs, [`resolve_output_spec`]
299/// has already picked one winner per field - a caller's flag replaces the
300/// blueprint's line rather than joining it - so there is exactly one shape here
301/// and it is the one that should govern. The sentence is scoped to presentation
302/// so a bare `format` does not read as licence to drop content.
303///
304/// Returns an empty string for a spec that constrains nothing, so callers can
305/// append it unconditionally.
306pub fn describe_spec(spec: &OutputSpec) -> String {
307    let mut parts = Vec::new();
308    if let Some(format) = &spec.format {
309        parts.push(format!("Return it in this format: {format}."));
310    }
311    if let Some(instructions) = &spec.instructions {
312        parts.push(instructions.clone());
313    }
314    if let Some(schema) = &spec.schema {
315        parts.push(format!(
316            "It must be JSON valid against this schema:\n{schema}"
317        ));
318    }
319    if let Some(example) = &spec.example {
320        parts.push(format!(
321            "Here is an example of the expected shape:\n{example}"
322        ));
323    }
324    if !parts.is_empty() {
325        parts.push(
326            "This governs how the answer is presented. Where anything else you were told says \
327             to present it differently - its length, its structure, what to lead with - follow \
328             this."
329                .to_string(),
330        );
331    }
332    parts.join("\n\n")
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use serde_json::json;
339
340    fn spec(format: Option<&str>, schema: Option<serde_json::Value>) -> OutputSpec {
341        OutputSpec {
342            format: format.map(str::to_string),
343            schema,
344            ..OutputSpec::default()
345        }
346    }
347
348    /// The artifacts list is how an answer points at what it could never
349    /// contain: a dataset, a report, a directory of generated files. It travels
350    /// with the descriptor so a caller can fetch them without parsing paths back
351    /// out of prose.
352    #[test]
353    fn artifacts_attach_to_a_submission_and_reach_the_descriptor() {
354        let output = FinalOutput::new(
355            "the summary",
356            Some("markdown".to_string()),
357            "present".to_string(),
358            42,
359        )
360        .with_artifacts(vec![
361            "data/dataset.csv".to_string(),
362            "report.pdf".to_string(),
363        ]);
364
365        assert_eq!(output.artifacts, ["data/dataset.csv", "report.pdf"]);
366        assert_eq!(output.descriptor().artifacts, output.artifacts);
367        // The bytes stay out of the descriptor: it goes in `meta.json`, which is
368        // read for every run in a listing.
369        assert_eq!(output.descriptor().bytes, "the summary".len());
370    }
371
372    #[test]
373    fn a_submission_carries_no_artifacts_unless_given_some() {
374        assert!(
375            FinalOutput::new("x", None, "present".to_string(), 0)
376                .artifacts
377                .is_empty()
378        );
379    }
380
381    #[test]
382    fn empty_spec_constrains_nothing() {
383        assert!(OutputSpec::default().is_empty());
384        assert!(!spec(Some("json"), None).is_empty());
385        assert!(!spec(None, Some(json!({}))).is_empty());
386        assert!(
387            !OutputSpec {
388                instructions: Some("be brief".to_string()),
389                ..OutputSpec::default()
390            }
391            .is_empty()
392        );
393        assert!(
394            !OutputSpec {
395                example: Some("<doc/>".to_string()),
396                ..OutputSpec::default()
397            }
398            .is_empty()
399        );
400    }
401
402    #[test]
403    fn no_level_asking_for_output_resolves_to_none() {
404        assert_eq!(resolve_output_spec(None, None, None), None);
405    }
406
407    #[test]
408    fn later_levels_win_field_by_field() {
409        let agent = OutputSpec {
410            format: Some("markdown".to_string()),
411            instructions: Some("agent guidance".to_string()),
412            example: Some("agent example".to_string()),
413            schema: None,
414            validator: None,
415        };
416        let stage = OutputSpec {
417            instructions: Some("stage guidance".to_string()),
418            ..OutputSpec::default()
419        };
420        let resolved = resolve_output_spec(Some(&agent), Some(&stage), None)
421            .expect("some level asked for an output");
422        // The stage narrows one field; the rest fall through to the agent.
423        assert_eq!(resolved.instructions.as_deref(), Some("stage guidance"));
424        assert_eq!(resolved.format.as_deref(), Some("markdown"));
425        assert_eq!(resolved.example.as_deref(), Some("agent example"));
426    }
427
428    #[test]
429    fn a_stage_alone_can_ask_for_an_output() {
430        let stage = spec(Some("a2ui"), None);
431        let resolved =
432            resolve_output_spec(None, Some(&stage), None).expect("the stage asked for one");
433        assert_eq!(resolved.format.as_deref(), Some("a2ui"));
434    }
435
436    /// The bug this replaced: naming the format the blueprint already declared
437    /// dropped the schema, so a caller who asked for exactly what was on offer
438    /// lost the check that came with it.
439    #[test]
440    fn re_stating_the_declared_format_keeps_its_shape_checks() {
441        let agent = OutputSpec {
442            format: Some("json".to_string()),
443            schema: Some(json!({"type": "object"})),
444            validator: Some("v.rhai".to_string()),
445            ..OutputSpec::default()
446        };
447        let request = spec(Some("json"), None);
448        let resolved = resolve_output_spec(Some(&agent), None, Some(&request))
449            .expect("the agent asked for one");
450        assert_eq!(resolved.schema, Some(json!({"type": "object"})));
451        assert_eq!(resolved.validator.as_deref(), Some("v.rhai"));
452    }
453
454    /// A Rhai validator is written for one format, so it retires with the schema
455    /// when a caller asks for a different one.
456    #[test]
457    fn reshaping_retires_the_validator_too() {
458        let agent = OutputSpec {
459            format: Some("a2ui".to_string()),
460            validator: Some("a2ui.rhai".to_string()),
461            ..OutputSpec::default()
462        };
463        let request = spec(Some("xml"), None);
464        let resolved = resolve_output_spec(Some(&agent), None, Some(&request))
465            .expect("the agent asked for one");
466        assert_eq!(resolved.format.as_deref(), Some("xml"));
467        assert_eq!(resolved.validator, None);
468    }
469
470    /// A caller that brings its own checks keeps them.
471    #[test]
472    fn a_caller_can_supply_shape_checks_with_its_own_format() {
473        let agent = OutputSpec {
474            format: Some("a2ui".to_string()),
475            validator: Some("a2ui.rhai".to_string()),
476            ..OutputSpec::default()
477        };
478        let request = OutputSpec {
479            format: Some("json".to_string()),
480            schema: Some(json!({"type": "array"})),
481            ..OutputSpec::default()
482        };
483        let resolved = resolve_output_spec(Some(&agent), None, Some(&request))
484            .expect("the agent asked for one");
485        assert_eq!(resolved.schema, Some(json!({"type": "array"})));
486        assert_eq!(resolved.validator, None, "the agent's own is still retired");
487    }
488
489    #[test]
490    fn a_caller_reshaping_the_output_drops_the_declared_schema() {
491        let agent = spec(Some("json"), Some(json!({"type": "object"})));
492        // Caller names a different format and supplies no schema of its own:
493        // the schema written for the old shape no longer applies.
494        let request = spec(Some("a2ui"), None);
495        let resolved = resolve_output_spec(Some(&agent), None, Some(&request))
496            .expect("the agent asked for one");
497        assert_eq!(resolved.format.as_deref(), Some("a2ui"));
498        assert_eq!(resolved.schema, None);
499    }
500
501    #[test]
502    fn a_caller_supplying_its_own_schema_keeps_it() {
503        let agent = spec(Some("json"), Some(json!({"type": "object"})));
504        let request = spec(Some("json"), Some(json!({"type": "array"})));
505        let resolved = resolve_output_spec(Some(&agent), None, Some(&request))
506            .expect("the agent asked for one");
507        assert_eq!(resolved.schema, Some(json!({"type": "array"})));
508    }
509
510    #[test]
511    fn a_caller_that_names_no_format_leaves_the_schema_alone() {
512        let agent = spec(Some("json"), Some(json!({"type": "object"})));
513        // Only instructions differ, so the declared shape still stands.
514        let request = OutputSpec {
515            instructions: Some("keep it short".to_string()),
516            ..OutputSpec::default()
517        };
518        let resolved = resolve_output_spec(Some(&agent), None, Some(&request))
519            .expect("the agent asked for one");
520        assert_eq!(resolved.format.as_deref(), Some("json"));
521        assert_eq!(resolved.schema, Some(json!({"type": "object"})));
522    }
523
524    #[test]
525    fn short_content_is_stored_verbatim() {
526        let out = FinalOutput::new(
527            "done: 3 files",
528            Some("markdown".to_string()),
529            "wrap".into(),
530            7,
531        );
532        assert_eq!(out.content, "done: 3 files");
533        assert_eq!(out.format.as_deref(), Some("markdown"));
534        assert_eq!(out.stage, "wrap");
535        assert_eq!(out.submitted_at, 7);
536        assert!(!out.truncated);
537    }
538
539    #[test]
540    fn oversized_content_is_cut_at_a_char_boundary_and_flagged() {
541        // A multi-byte character straddling the cap: slicing by byte index here
542        // is what once aborted the daemon, so the cut must walk back.
543        let mut content = "a".repeat(MAX_FINAL_OUTPUT_BYTES - 1);
544        content.push('\u{1f600}');
545        let out = FinalOutput::new(&content, None, "wrap".into(), 0);
546        assert!(out.truncated);
547        assert_eq!(out.content.len(), MAX_FINAL_OUTPUT_BYTES - 1);
548        assert!(out.format.is_none());
549    }
550
551    #[test]
552    fn describe_spec_is_empty_when_nothing_is_constrained() {
553        assert_eq!(describe_spec(&OutputSpec::default()), "");
554    }
555
556    #[test]
557    fn describe_spec_renders_every_field_it_has() {
558        let described = describe_spec(&OutputSpec {
559            format: Some("a2ui".to_string()),
560            instructions: Some("One card per finding.".to_string()),
561            example: Some("{\"root\": {}}".to_string()),
562            schema: Some(json!({"type": "object"})),
563            validator: None,
564        });
565        assert!(described.contains("Return it in this format: a2ui."));
566        assert!(described.contains("One card per finding."));
567        assert!(described.contains("valid against this schema"));
568        assert!(described.contains("{\"root\": {}}"));
569    }
570
571    /// Issue #282. Without this the spec and the stage's own system prompt are
572    /// two peer instructions, and a strongly-shaped stage prompt wins on some
573    /// models and loses on others.
574    #[test]
575    fn a_constrained_spec_says_it_outranks_the_stage_prompt() {
576        let described = describe_spec(&OutputSpec {
577            instructions: Some("Reply with only the integer.".to_string()),
578            ..OutputSpec::default()
579        });
580        assert!(
581            described.contains("Where anything else you were told"),
582            "{described}"
583        );
584        // Last, so it is read as governing what precedes it rather than as one
585        // more line the next paragraph can override.
586        assert!(
587            described.trim_end().ends_with("follow this."),
588            "{described}"
589        );
590    }
591
592    /// A format on its own is still a shape, so it still outranks a prompt that
593    /// describes a different one.
594    #[test]
595    fn a_format_only_spec_claims_precedence_too() {
596        let described = describe_spec(&OutputSpec {
597            format: Some("text".to_string()),
598            ..OutputSpec::default()
599        });
600        assert!(
601            described.contains("Where anything else you were told"),
602            "{described}"
603        );
604    }
605
606    /// The claim is scoped to presentation. A spec that constrains nothing must
607    /// not tell a model to disregard its stage prompt.
608    #[test]
609    fn an_unconstrained_spec_claims_nothing() {
610        assert!(!describe_spec(&OutputSpec::default()).contains("follow this"));
611    }
612
613    #[test]
614    fn a_spec_round_trips_through_serde() {
615        let original = spec(Some("a2ui"), Some(json!({"type": "object"})));
616        let text = serde_json::to_string(&original).expect("a spec serializes");
617        let back: OutputSpec = serde_json::from_str(&text).expect("and deserializes");
618        assert_eq!(back, original);
619        // Unset fields stay off the wire rather than serializing as nulls.
620        assert!(!text.contains("instructions"));
621    }
622
623    #[test]
624    fn a_final_output_round_trips_through_serde() {
625        let original = FinalOutput::new("answer", None, "wrap".into(), 1);
626        let text = serde_json::to_string(&original).expect("an output serializes");
627        let back: FinalOutput = serde_json::from_str(&text).expect("and deserializes");
628        assert_eq!(back, original);
629    }
630}