Skip to main content

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}