cactus_rs/needle/completion.rs
1//! The JSON envelope the engine answers with, as Rust types.
2//!
3//! ## Overview
4//!
5//! Every [`needle_complete`](cactus_sys::needle_complete) call writes one JSON object into the
6//! caller's buffer, and [`Completion`] is that object parsed. A turn that reaches for tools looks
7//! like this:
8//!
9//! ```json
10//! {"type":"call","success":true,"function_calls":[{"name":"set_light",
11//! "arguments":{"room":"kitchen","on":true}}],"suppressed_calls":[],
12//! "reasoning":"room 'kitchen' from query; on true from 'on'","confidence":1.0,
13//! "prefill_tps":1966.8,"decode_tps":809.7,"peak_ram_mb":90.9,
14//! "validation":{"ungrounded":[],"negation":false}}
15//! ```
16//!
17//! A turn that does not looks the same with `"type":"respond"`, no calls, no reasoning and a
18//! confidence of zero. A failure is a third shape, `{"type":"error","error":"..."}`, which
19//! [`Completion`]'s [`FromStr`] turns into [`Error::Complete`] rather than a value.
20//!
21//! A turn completed from a speech clip appends the transcript under an `audio_` prefix
22//! (`"audio_text":"Turn on the kitchen light.","audio_language":"en",...`), which
23//! [`Completion::audio`] reads as a [`Transcript`].
24//!
25//! Every field is `#[serde(default)]` and every type is `#[non_exhaustive]`: an engine that grows
26//! a field, or drops one, still parses.
27//!
28//! ## Usage
29//!
30//! ```rust
31//! use cactus_rs::needle::Completion;
32//!
33//! let envelope = r#"{"type":"respond","success":true,"function_calls":[],
34//! "reasoning":null,"confidence":0.0,"decode_tps":812.0}"#;
35//!
36//! let completion: Completion = envelope.parse()?;
37//! assert!(completion.kind().is_respond());
38//! assert!(completion.calls().is_empty());
39//! # Ok::<(), cactus_rs::Error>(())
40//! ```
41
42use std::str::FromStr;
43
44use serde::de::DeserializeOwned;
45use serde::{Deserialize, Deserializer, Serialize};
46use serde_json::Value;
47
48use crate::error::{Error, Result};
49use crate::whistle::{Transcript, Word};
50
51/// What the engine decided to do with a turn.
52///
53/// # Examples
54///
55/// ```rust
56/// use cactus_rs::needle::Kind;
57///
58/// assert!(Kind::Call.is_call());
59/// assert!(!Kind::Call.is_respond());
60/// ```
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
62#[serde(rename_all = "lowercase")]
63#[non_exhaustive]
64pub enum Kind {
65 /// The engine answered with tool calls.
66 Call,
67 /// The engine answered in prose, with no tool calls.
68 Respond,
69 /// A tag this crate does not know, from an engine newer than the pinned one.
70 #[default]
71 #[serde(other)]
72 Unknown,
73}
74
75impl Kind {
76 /// Whether the engine answered with tool calls.
77 ///
78 /// # Examples
79 ///
80 /// ```rust
81 /// use cactus_rs::needle::Kind;
82 ///
83 /// assert!(Kind::Call.is_call());
84 /// ```
85 #[inline]
86 #[must_use]
87 pub const fn is_call(&self) -> bool {
88 matches!(self, Kind::Call)
89 }
90
91 /// Whether the engine answered in prose.
92 ///
93 /// # Examples
94 ///
95 /// ```rust
96 /// use cactus_rs::needle::Kind;
97 ///
98 /// assert!(Kind::Respond.is_respond());
99 /// ```
100 #[inline]
101 #[must_use]
102 pub const fn is_respond(&self) -> bool {
103 matches!(self, Kind::Respond)
104 }
105
106 /// Whether the tag was one this crate does not know.
107 ///
108 /// # Examples
109 ///
110 /// ```rust
111 /// use cactus_rs::needle::Kind;
112 ///
113 /// assert!(Kind::Unknown.is_unknown());
114 /// ```
115 #[inline]
116 #[must_use]
117 pub const fn is_unknown(&self) -> bool {
118 matches!(self, Kind::Unknown)
119 }
120}
121
122/// One tool call the engine chose to make.
123///
124/// # Examples
125///
126/// ```rust
127/// use cactus_rs::needle::Call;
128///
129/// let call: Call = serde_json::from_str(
130/// r#"{"name":"set_light","arguments":{"room":"kitchen","on":true}}"#,
131/// )?;
132///
133/// assert_eq!(call.name(), "set_light");
134/// assert_eq!(call.arguments_json(), r#"{"room":"kitchen","on":true}"#);
135/// # Ok::<(), serde_json::Error>(())
136/// ```
137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
138#[non_exhaustive]
139pub struct Call {
140 #[serde(default)]
141 name: String,
142 #[serde(default)]
143 arguments: Value,
144}
145
146impl Call {
147 /// The name of the declared [`Tool`](crate::needle::Tool) the engine chose.
148 ///
149 /// # Examples
150 ///
151 /// ```rust
152 /// use cactus_rs::needle::Call;
153 ///
154 /// let call: Call = serde_json::from_str(r#"{"name":"ping","arguments":{}}"#)?;
155 /// assert_eq!(call.name(), "ping");
156 /// # Ok::<(), serde_json::Error>(())
157 /// ```
158 #[inline]
159 #[must_use]
160 pub fn name(&self) -> &str {
161 &self.name
162 }
163
164 /// The arguments the engine filled in, as JSON.
165 ///
166 /// # Examples
167 ///
168 /// ```rust
169 /// use cactus_rs::needle::Call;
170 ///
171 /// let call: Call = serde_json::from_str(r#"{"name":"ping","arguments":{"host":"a"}}"#)?;
172 /// assert_eq!(call.arguments()["host"], "a");
173 /// # Ok::<(), serde_json::Error>(())
174 /// ```
175 #[inline]
176 #[must_use]
177 pub fn arguments(&self) -> &Value {
178 &self.arguments
179 }
180
181 /// The arguments as a compact JSON string, ready to print or log.
182 ///
183 /// # Examples
184 ///
185 /// ```rust
186 /// use cactus_rs::needle::Call;
187 ///
188 /// let call: Call = serde_json::from_str(r#"{"name":"ping","arguments":{"host":"a"}}"#)?;
189 /// assert_eq!(call.arguments_json(), r#"{"host":"a"}"#);
190 /// # Ok::<(), serde_json::Error>(())
191 /// ```
192 #[must_use]
193 pub fn arguments_json(&self) -> String {
194 // Serializing a `Value` cannot fail: it has no non-string map keys and no non-finite
195 // numbers to reject.
196 serde_json::to_string(&self.arguments).unwrap_or_default()
197 }
198
199 /// Parses the arguments into a Rust type.
200 ///
201 /// # Examples
202 ///
203 /// ```rust
204 /// use cactus_rs::needle::Call;
205 /// use serde::Deserialize;
206 ///
207 /// #[derive(Deserialize)]
208 /// struct SetLight {
209 /// room: String,
210 /// on: bool,
211 /// }
212 ///
213 /// let call: Call = serde_json::from_str(
214 /// r#"{"name":"set_light","arguments":{"room":"kitchen","on":true}}"#,
215 /// )?;
216 ///
217 /// let args: SetLight = call.parse()?;
218 /// assert_eq!(args.room, "kitchen");
219 /// assert!(args.on);
220 /// # Ok::<(), Box<dyn std::error::Error>>(())
221 /// ```
222 ///
223 /// # Errors
224 ///
225 /// Returns [`Error::Arguments`] when the arguments the engine produced do not fit `T`.
226 pub fn parse<T>(&self) -> Result<T>
227 where
228 T: DeserializeOwned,
229 {
230 serde_json::from_value(self.arguments.clone()).map_err(|source| Error::Arguments { source })
231 }
232}
233
234/// What the turn cost, as the engine measured it.
235///
236/// # Examples
237///
238/// ```rust
239/// use cactus_rs::needle::Completion;
240///
241/// let completion: Completion =
242/// r#"{"type":"respond","prefill_tps":1966.8,"decode_tps":809.7,"peak_ram_mb":90.9}"#
243/// .parse()?;
244///
245/// assert_eq!(completion.stats().decode_tps(), 809.7);
246/// # Ok::<(), cactus_rs::Error>(())
247/// ```
248#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
249#[non_exhaustive]
250pub struct Stats {
251 #[serde(default)]
252 prefill_tps: f32,
253 #[serde(default)]
254 decode_tps: f32,
255 #[serde(default)]
256 peak_ram_mb: f32,
257}
258
259impl Stats {
260 /// Tokens per second while reading the prompt.
261 ///
262 /// # Examples
263 ///
264 /// ```rust
265 /// use cactus_rs::needle::Completion;
266 ///
267 /// let completion: Completion = r#"{"type":"respond","prefill_tps":1966.8}"#.parse()?;
268 /// assert!(completion.stats().prefill_tps() > 0.0);
269 /// # Ok::<(), cactus_rs::Error>(())
270 /// ```
271 #[inline]
272 #[must_use]
273 pub const fn prefill_tps(&self) -> f32 {
274 self.prefill_tps
275 }
276
277 /// Tokens per second while writing the answer.
278 ///
279 /// # Examples
280 ///
281 /// ```rust
282 /// use cactus_rs::needle::Completion;
283 ///
284 /// let completion: Completion = r#"{"type":"respond","decode_tps":809.7}"#.parse()?;
285 /// assert!(completion.stats().decode_tps() > 0.0);
286 /// # Ok::<(), cactus_rs::Error>(())
287 /// ```
288 #[inline]
289 #[must_use]
290 pub const fn decode_tps(&self) -> f32 {
291 self.decode_tps
292 }
293
294 /// Peak resident memory during the turn, in megabytes.
295 ///
296 /// # Examples
297 ///
298 /// ```rust
299 /// use cactus_rs::needle::Completion;
300 ///
301 /// let completion: Completion = r#"{"type":"respond","peak_ram_mb":90.9}"#.parse()?;
302 /// assert!(completion.stats().peak_ram_mb() > 0.0);
303 /// # Ok::<(), cactus_rs::Error>(())
304 /// ```
305 #[inline]
306 #[must_use]
307 pub const fn peak_ram_mb(&self) -> f32 {
308 self.peak_ram_mb
309 }
310}
311
312/// The engine's own check on whether the calls it made follow from the input.
313///
314/// # Examples
315///
316/// ```rust
317/// use cactus_rs::needle::Completion;
318///
319/// let grounded: Completion =
320/// r#"{"type":"call","validation":{"ungrounded":[],"negation":false}}"#.parse()?;
321/// assert!(grounded.validation().is_grounded());
322///
323/// let invented: Completion =
324/// r#"{"type":"call","validation":{"ungrounded":["room"],"negation":false}}"#.parse()?;
325/// assert!(!invented.validation().is_grounded());
326/// # Ok::<(), cactus_rs::Error>(())
327/// ```
328#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
329#[non_exhaustive]
330pub struct Validation {
331 #[serde(default)]
332 ungrounded: Vec<String>,
333 #[serde(default)]
334 negation: bool,
335}
336
337impl Validation {
338 /// Argument values the engine could not trace back to the input.
339 ///
340 /// # Examples
341 ///
342 /// ```rust
343 /// use cactus_rs::needle::Completion;
344 ///
345 /// let completion: Completion =
346 /// r#"{"type":"call","validation":{"ungrounded":["kitchen"]}}"#.parse()?;
347 /// assert_eq!(completion.validation().ungrounded(), ["kitchen"]);
348 /// # Ok::<(), cactus_rs::Error>(())
349 /// ```
350 #[inline]
351 #[must_use]
352 pub fn ungrounded(&self) -> &[String] {
353 &self.ungrounded
354 }
355
356 /// Whether the input negated the action the calls would take.
357 ///
358 /// # Examples
359 ///
360 /// ```rust
361 /// use cactus_rs::needle::Completion;
362 ///
363 /// let completion: Completion = r#"{"type":"call","validation":{"negation":true}}"#.parse()?;
364 /// assert!(completion.validation().negation());
365 /// # Ok::<(), cactus_rs::Error>(())
366 /// ```
367 #[inline]
368 #[must_use]
369 pub const fn negation(&self) -> bool {
370 self.negation
371 }
372
373 /// Whether nothing was flagged: no invented values and no negation.
374 ///
375 /// # Examples
376 ///
377 /// ```rust
378 /// use cactus_rs::needle::Completion;
379 ///
380 /// let completion: Completion =
381 /// r#"{"type":"call","validation":{"ungrounded":[],"negation":false}}"#.parse()?;
382 /// assert!(completion.validation().is_grounded());
383 /// # Ok::<(), cactus_rs::Error>(())
384 /// ```
385 #[inline]
386 #[must_use]
387 pub fn is_grounded(&self) -> bool {
388 self.ungrounded.is_empty() && !self.negation
389 }
390}
391
392/// One turn's answer from the engine.
393///
394/// # Examples
395///
396/// ```rust
397/// use cactus_rs::needle::Completion;
398///
399/// let completion: Completion = r#"{"type":"call","success":true,
400/// "function_calls":[{"name":"set_light","arguments":{"room":"kitchen","on":true}}],
401/// "confidence":1.0,"validation":{"ungrounded":[],"negation":false}}"#
402/// .parse()?;
403///
404/// assert!(completion.kind().is_call());
405/// assert_eq!(completion.calls().len(), 1);
406/// assert_eq!(completion.grounded_calls().len(), 1);
407/// assert_eq!(completion.confidence(), 1.0);
408/// # Ok::<(), cactus_rs::Error>(())
409/// ```
410#[derive(Debug, Clone, PartialEq, Deserialize)]
411#[non_exhaustive]
412pub struct Completion {
413 #[serde(rename = "type", default)]
414 kind: Kind,
415 // Kept for `FromStr`, which turns a failed envelope into an error rather than a value.
416 #[serde(default = "succeeded")]
417 success: bool,
418 #[serde(default)]
419 error: Option<String>,
420 #[serde(default)]
421 error_code: Option<String>,
422 #[serde(rename = "function_calls", default)]
423 calls: Vec<Call>,
424 #[serde(default)]
425 suppressed_calls: Vec<Call>,
426 #[serde(default)]
427 reasoning: Option<String>,
428 #[serde(default)]
429 reason: Option<String>,
430 #[serde(default)]
431 confidence: f32,
432 #[serde(flatten)]
433 stats: Stats,
434 #[serde(default)]
435 validation: Validation,
436 #[serde(flatten, deserialize_with = "audio_fields")]
437 audio: Option<Transcript>,
438}
439
440/// The `audio_*` keys an audio turn appends after the text ones, as the engine writes them.
441#[derive(Deserialize)]
442struct AudioFields {
443 #[serde(default)]
444 audio_text: Option<String>,
445 #[serde(default)]
446 audio_language: Option<String>,
447 #[serde(default)]
448 audio_words: Vec<Word>,
449 #[serde(default)]
450 audio_ttft_ms: f32,
451 #[serde(default)]
452 audio_decode_tps: f32,
453}
454
455/// Reads the `audio_*` keys into a [`Transcript`], or `None` for a typed turn, which has neither
456/// `audio_text` nor `audio_language`.
457fn audio_fields<'de, D>(deserializer: D) -> std::result::Result<Option<Transcript>, D::Error>
458where
459 D: Deserializer<'de>,
460{
461 let fields = AudioFields::deserialize(deserializer)?;
462 if fields.audio_text.is_none() && fields.audio_language.is_none() {
463 return Ok(None);
464 }
465 Ok(Some(Transcript::from_parts(
466 fields.audio_text.unwrap_or_default(),
467 fields.audio_language.unwrap_or_default(),
468 fields.audio_words,
469 fields.audio_ttft_ms,
470 fields.audio_decode_tps,
471 )))
472}
473
474/// The default for a missing `success` field: an envelope that omits it did not fail.
475fn succeeded() -> bool {
476 true
477}
478
479impl Completion {
480 /// What the engine decided to do with the turn.
481 ///
482 /// # Examples
483 ///
484 /// ```rust
485 /// use cactus_rs::needle::Completion;
486 ///
487 /// let completion: Completion = r#"{"type":"respond"}"#.parse()?;
488 /// assert!(completion.kind().is_respond());
489 /// # Ok::<(), cactus_rs::Error>(())
490 /// ```
491 #[inline]
492 #[must_use]
493 pub const fn kind(&self) -> Kind {
494 self.kind
495 }
496
497 /// Every tool call the engine made, grounded or not.
498 ///
499 /// # Examples
500 ///
501 /// ```rust
502 /// use cactus_rs::needle::Completion;
503 ///
504 /// let completion: Completion =
505 /// r#"{"type":"call","function_calls":[{"name":"ping","arguments":{}}]}"#.parse()?;
506 /// assert_eq!(completion.calls()[0].name(), "ping");
507 /// # Ok::<(), cactus_rs::Error>(())
508 /// ```
509 #[inline]
510 #[must_use]
511 pub fn calls(&self) -> &[Call] {
512 &self.calls
513 }
514
515 /// The calls, or an empty slice when [`Validation`] flagged the turn.
516 ///
517 /// Upstream's harness discards every call in a turn whose arguments it could not ground, or
518 /// whose input negated the action, rather than filtering them one by one. This mirrors that:
519 /// act on these when a wrong call would be expensive, and on [`Completion::calls`] when it
520 /// would not.
521 ///
522 /// # Examples
523 ///
524 /// ```rust
525 /// use cactus_rs::needle::Completion;
526 ///
527 /// let flagged: Completion = r#"{"type":"call",
528 /// "function_calls":[{"name":"ping","arguments":{}}],
529 /// "validation":{"ungrounded":["host"],"negation":false}}"#
530 /// .parse()?;
531 ///
532 /// assert_eq!(flagged.calls().len(), 1);
533 /// assert!(flagged.grounded_calls().is_empty());
534 /// # Ok::<(), cactus_rs::Error>(())
535 /// ```
536 #[inline]
537 #[must_use]
538 pub fn grounded_calls(&self) -> &[Call] {
539 if self.validation.is_grounded() {
540 &self.calls
541 } else {
542 &[]
543 }
544 }
545
546 /// Calls the engine considered and then dropped.
547 ///
548 /// # Examples
549 ///
550 /// ```rust
551 /// use cactus_rs::needle::Completion;
552 ///
553 /// let completion: Completion = r#"{"type":"call","suppressed_calls":[]}"#.parse()?;
554 /// assert!(completion.suppressed_calls().is_empty());
555 /// # Ok::<(), cactus_rs::Error>(())
556 /// ```
557 #[inline]
558 #[must_use]
559 pub fn suppressed_calls(&self) -> &[Call] {
560 &self.suppressed_calls
561 }
562
563 /// Why the engine made these calls, in its own words, when it said.
564 ///
565 /// # Examples
566 ///
567 /// ```rust
568 /// use cactus_rs::needle::Completion;
569 ///
570 /// let completion: Completion = r#"{"type":"call","reasoning":"room from query"}"#.parse()?;
571 /// assert_eq!(completion.reasoning(), Some("room from query"));
572 /// # Ok::<(), cactus_rs::Error>(())
573 /// ```
574 #[inline]
575 #[must_use]
576 pub fn reasoning(&self) -> Option<&str> {
577 self.reasoning.as_deref()
578 }
579
580 /// Why the engine took no action, when it said.
581 ///
582 /// # Examples
583 ///
584 /// ```rust
585 /// use cactus_rs::needle::Completion;
586 ///
587 /// let completion: Completion = r#"{"type":"respond","reason":null}"#.parse()?;
588 /// assert_eq!(completion.reason(), None);
589 /// # Ok::<(), cactus_rs::Error>(())
590 /// ```
591 #[inline]
592 #[must_use]
593 pub fn reason(&self) -> Option<&str> {
594 self.reason.as_deref()
595 }
596
597 /// How sure the engine was, from zero to one. A turn with no calls reports zero.
598 ///
599 /// # Examples
600 ///
601 /// ```rust
602 /// use cactus_rs::needle::Completion;
603 ///
604 /// let completion: Completion = r#"{"type":"call","confidence":1.0}"#.parse()?;
605 /// assert_eq!(completion.confidence(), 1.0);
606 /// # Ok::<(), cactus_rs::Error>(())
607 /// ```
608 #[inline]
609 #[must_use]
610 pub const fn confidence(&self) -> f32 {
611 self.confidence
612 }
613
614 /// What the turn cost.
615 ///
616 /// # Examples
617 ///
618 /// ```rust
619 /// use cactus_rs::needle::Completion;
620 ///
621 /// let completion: Completion = r#"{"type":"respond","decode_tps":809.7}"#.parse()?;
622 /// assert_eq!(completion.stats().decode_tps(), 809.7);
623 /// # Ok::<(), cactus_rs::Error>(())
624 /// ```
625 #[inline]
626 #[must_use]
627 pub const fn stats(&self) -> &Stats {
628 &self.stats
629 }
630
631 /// The engine's grounding check on this turn.
632 ///
633 /// # Examples
634 ///
635 /// ```rust
636 /// use cactus_rs::needle::Completion;
637 ///
638 /// let completion: Completion = r#"{"type":"call","validation":{"negation":false}}"#.parse()?;
639 /// assert!(completion.validation().is_grounded());
640 /// # Ok::<(), cactus_rs::Error>(())
641 /// ```
642 #[inline]
643 #[must_use]
644 pub const fn validation(&self) -> &Validation {
645 &self.validation
646 }
647
648 /// What the engine heard, for a turn completed from a speech clip; `None` for a typed one.
649 ///
650 /// Read from the `audio_text`, `audio_language`, `audio_words`, `audio_ttft_ms` and
651 /// `audio_decode_tps` fields an audio turn adds to the envelope. The words are there only
652 /// when word timestamps were asked for.
653 ///
654 /// # Examples
655 ///
656 /// ```rust
657 /// use cactus_rs::needle::Completion;
658 /// use cactus_rs::whistle::Language;
659 ///
660 /// let completion: Completion = r#"{"type":"call","success":true,
661 /// "function_calls":[{"name":"set_light","arguments":{"room":"kitchen","on":false}}],
662 /// "audio_text":"Turn off the kitchen lights.","audio_language":"en",
663 /// "audio_ttft_ms":113.3,"audio_decode_tps":47.9}"#
664 /// .parse()?;
665 ///
666 /// let heard = completion.audio().expect("an audio turn");
667 /// assert_eq!(heard.text(), "Turn off the kitchen lights.");
668 /// assert_eq!(heard.language(), Some(Language::En));
669 ///
670 /// let typed: Completion = r#"{"type":"respond"}"#.parse()?;
671 /// assert!(typed.audio().is_none());
672 /// # Ok::<(), cactus_rs::Error>(())
673 /// ```
674 #[inline]
675 #[must_use]
676 pub const fn audio(&self) -> Option<&Transcript> {
677 self.audio.as_ref()
678 }
679}
680
681/// Parses an engine envelope, turning a reported failure into an error.
682impl FromStr for Completion {
683 type Err = Error;
684
685 /// # Errors
686 ///
687 /// Returns [`Error::Envelope`] when the text is not the JSON this crate expects, and
688 /// [`Error::Complete`] when it is a well-formed envelope reporting a failure.
689 fn from_str(s: &str) -> Result<Self> {
690 let completion: Completion =
691 serde_json::from_str(s).map_err(|error| Error::envelope(s, error))?;
692
693 if completion.success && completion.error.is_none() {
694 return Ok(completion);
695 }
696
697 let detail = match (&completion.error, &completion.error_code) {
698 (Some(error), Some(code)) => format!("{error} ({code})"),
699 (Some(error), None) => error.clone(),
700 (None, Some(code)) => format!("the engine reported failure {code}"),
701 (None, None) => "the engine reported a failure without saying why".to_owned(),
702 };
703 Err(Error::Complete { detail })
704 }
705}
706
707#[cfg(test)]
708mod tests {
709 use super::*;
710
711 /// The envelope a real macOS arm64 engine answered `turn the kitchen light on` with.
712 const CALL: &str = concat!(
713 r#"{"type":"call","success":true,"error":null,"error_code":null,"reason":null,"#,
714 r#""function_calls":[{"name":"set_light","arguments":{"room":"kitchen","on":true}}],"#,
715 r#""suppressed_calls":[],"#,
716 r#""reasoning":"room 'kitchen' from query; on true from 'on'","confidence":1.0000,"#,
717 r#""prefill_tps":1966.8,"decode_tps":809.7,"peak_ram_mb":90.9,"#,
718 r#""validation":{"ungrounded":[],"negation":false}}"#,
719 );
720
721 /// The same engine, asked something no tool covers.
722 const RESPOND: &str = r#"{"type":"respond","success":true,"error":null,"error_code":null,
723 "reason":null,"function_calls":[],"suppressed_calls":[],"reasoning":null,
724 "confidence":0.0,"prefill_tps":1902.1,"decode_tps":794.3,"peak_ram_mb":90.9,
725 "validation":{"ungrounded":[],"negation":false}}"#;
726
727 /// The pinned Linux x86_64 engine on `lights_en.wav`, with word timestamps on.
728 const AUDIO: &str = concat!(
729 r#"{"type":"call","success":true,"error":null,"error_code":null,"reason":null,"#,
730 r#""function_calls":[{"name":"set_light","arguments":{"room":"kitchen","on":false}}],"#,
731 r#""suppressed_calls":[],"reasoning":"room 'kitchen' from query; 'off' -> on false.","#,
732 r#""confidence":0.9485,"prefill_tps":41.3,"decode_tps":128.8,"peak_ram_mb":97.1,"#,
733 r#""validation":{"ungrounded":[],"negation":false},"#,
734 r#""audio_text":"Turn off the kitchen lights.","audio_language":"en","audio_words":["#,
735 r#"{"word":"Turn","start":0.16,"end":0.40,"probability":0.850},"#,
736 r#"{"word":"off","start":0.40,"end":0.72,"probability":0.986},"#,
737 r#"{"word":"the","start":0.72,"end":0.96,"probability":0.992},"#,
738 r#"{"word":"kitchen","start":0.96,"end":1.28,"probability":0.976},"#,
739 r#"{"word":"lights.","start":1.28,"end":1.68,"probability":0.990}],"#,
740 r#""audio_ttft_ms":96.0,"audio_decode_tps":63.8}"#,
741 );
742
743 /// The same engine on one second of silence, without word timestamps.
744 const AUDIO_SILENCE: &str = concat!(
745 r#"{"type":"call","success":true,"error":null,"error_code":null,"reason":null,"#,
746 r#""function_calls":[],"suppressed_calls":[],"#,
747 r#""reasoning":"No tool available for smart home control.","confidence":0.9239,"#,
748 r#""prefill_tps":29.6,"decode_tps":157.7,"peak_ram_mb":97.1,"#,
749 r#""audio_text":"","audio_language":"","audio_ttft_ms":0.0,"audio_decode_tps":0.0}"#,
750 );
751
752 #[test]
753 fn an_audio_envelope_carries_its_transcript() {
754 let completion: Completion = AUDIO.parse().expect("the sample envelope parses");
755
756 assert!(completion.kind().is_call());
757 assert_eq!(
758 completion.calls()[0].arguments_json(),
759 r#"{"room":"kitchen","on":false}"#
760 );
761 assert_eq!(completion.stats().decode_tps(), 128.8);
762
763 let heard = completion.audio().expect("an audio turn");
764 assert_eq!(heard.text(), "Turn off the kitchen lights.");
765 assert_eq!(heard.language(), Some(crate::whistle::Language::En));
766 assert_eq!(heard.ttft_ms(), 96.0);
767 assert_eq!(heard.decode_tps(), 63.8);
768 assert_eq!(heard.words().len(), 5);
769 assert_eq!(heard.words()[3].word(), "kitchen");
770 assert_eq!(heard.words()[3].start(), 0.96);
771 }
772
773 #[test]
774 fn a_silent_audio_turn_has_an_empty_transcript() {
775 let completion: Completion = AUDIO_SILENCE.parse().expect("the sample envelope parses");
776 let heard = completion.audio().expect("still an audio turn");
777 assert!(heard.is_empty());
778 assert_eq!(heard.language(), None);
779 assert!(heard.words().is_empty());
780 }
781
782 #[test]
783 fn a_typed_turn_has_no_transcript() {
784 let completion: Completion = CALL.parse().expect("the sample envelope parses");
785 assert!(completion.audio().is_none());
786
787 // Timings alone do not make a transcript.
788 let timings: Completion =
789 r#"{"type":"respond","audio_ttft_ms":1.0}"#.parse().expect("parses");
790 assert!(timings.audio().is_none());
791 }
792
793 #[test]
794 fn a_failed_audio_turn_is_still_an_error() {
795 // The engine merges what it heard even into an error envelope.
796 let envelope = concat!(
797 r#"{"type":"error","error":"needle_init not called","#,
798 r#""audio_text":"Turn off the kitchen lights.","audio_language":"en"}"#,
799 );
800 let error = envelope
801 .parse::<Completion>()
802 .expect_err("an error envelope");
803 assert!(
804 matches!(error, Error::Complete { ref detail } if detail == "needle_init not called")
805 );
806 }
807
808 #[test]
809 fn a_tool_call_envelope_parses() {
810 let completion: Completion = CALL.parse().expect("the sample envelope parses");
811
812 assert!(completion.kind().is_call());
813 assert_eq!(completion.calls().len(), 1);
814 assert_eq!(completion.calls()[0].name(), "set_light");
815 assert_eq!(
816 completion.calls()[0].arguments_json(),
817 r#"{"room":"kitchen","on":true}"#
818 );
819 assert_eq!(completion.confidence(), 1.0);
820 assert_eq!(completion.stats().decode_tps(), 809.7);
821 assert_eq!(completion.stats().prefill_tps(), 1966.8);
822 assert_eq!(completion.stats().peak_ram_mb(), 90.9);
823 assert!(completion.validation().is_grounded());
824 assert_eq!(completion.grounded_calls().len(), 1);
825 assert!(completion.suppressed_calls().is_empty());
826 assert!(completion.reasoning().is_some());
827 assert_eq!(completion.reason(), None);
828 }
829
830 #[test]
831 fn a_reply_envelope_parses() {
832 let completion: Completion = RESPOND.parse().expect("the sample envelope parses");
833
834 assert!(completion.kind().is_respond());
835 assert!(completion.calls().is_empty());
836 assert_eq!(completion.reasoning(), None);
837 assert_eq!(completion.confidence(), 0.0);
838 }
839
840 #[test]
841 fn an_error_envelope_becomes_a_complete_error() {
842 let envelope = r#"{"type":"error","error":"needle_init not called"}"#;
843 let error = envelope
844 .parse::<Completion>()
845 .expect_err("an error envelope is not a completion");
846
847 let Error::Complete { detail } = error else {
848 panic!("expected Error::Complete");
849 };
850 assert_eq!(detail, "needle_init not called");
851 }
852
853 #[test]
854 fn success_false_becomes_a_complete_error() {
855 let envelope = r#"{"type":"call","success":false,"error":"nope","error_code":"E7"}"#;
856 let error = envelope
857 .parse::<Completion>()
858 .expect_err("success:false is not a completion");
859
860 let Error::Complete { detail } = error else {
861 panic!("expected Error::Complete");
862 };
863 assert_eq!(detail, "nope (E7)");
864 }
865
866 #[test]
867 fn a_new_engine_tag_parses_as_unknown() {
868 let completion: Completion = r#"{"type":"reflect","success":true}"#
869 .parse()
870 .expect("an unknown tag is not a parse failure");
871 assert!(completion.kind().is_unknown());
872 }
873
874 #[test]
875 fn a_missing_field_is_not_a_parse_failure() {
876 let completion: Completion = r#"{"type":"respond"}"#.parse().expect("defaults fill in");
877 assert_eq!(completion.confidence(), 0.0);
878 assert_eq!(completion.stats().decode_tps(), 0.0);
879 assert!(completion.validation().is_grounded());
880 }
881
882 #[test]
883 fn junk_is_reported_with_its_text() {
884 let error = "not json"
885 .parse::<Completion>()
886 .expect_err("junk is not a completion");
887 let Error::Envelope { text, .. } = &error else {
888 panic!("expected Error::Envelope");
889 };
890 assert_eq!(text, "not json");
891 }
892
893 #[test]
894 fn a_negated_turn_has_no_grounded_calls() {
895 let completion: Completion = r#"{"type":"call",
896 "function_calls":[{"name":"ping","arguments":{}}],
897 "validation":{"ungrounded":[],"negation":true}}"#
898 .parse()
899 .expect("the envelope parses");
900
901 assert_eq!(completion.calls().len(), 1);
902 assert!(completion.grounded_calls().is_empty());
903 }
904}