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}