Skip to main content

ingot_runtime/
cassette.rs

1//! Recorded model exchanges, for deterministic offline runs.
2//!
3//! A cassette is what makes `ingot test` runnable in CI: no API key, no network,
4//! and the same answers every time. Replay matches interactions by order and
5//! verifies the request digest, so an edited prompt fails loudly instead of
6//! quietly reusing the previous recording.
7
8use std::cell::RefCell;
9use std::collections::BTreeMap;
10use std::path::Path;
11use std::rc::Rc;
12
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16use sha2::{Digest, Sha256};
17
18use crate::provider::{CompletionRequest, CompletionResponse, ModelProvider, ProviderError, Usage};
19use crate::tools::{
20    ApprovalRequest, ConsultError, ConsultRequest, HumanChannel, Interlocutor, ToolError, ToolHost,
21    ToolInvocation,
22};
23
24/// The version this crate writes.
25pub const CASSETTE_VERSION: &str = "0.3";
26
27/// Versions this crate can read.
28///
29/// Each is the one before it plus a list: 0.2 added `toolCalls`, 0.3 added
30/// `consultations`. So an older recording is a valid newer one with that list
31/// empty and keeps replaying unchanged. Re-recording moves it.
32pub const SUPPORTED_CASSETTE_VERSIONS: &[&str] = &["0.1", "0.2", "0.3"];
33
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase")]
36pub struct Cassette {
37    pub cassette_version: String,
38    /// Fully qualified name of the agent this was recorded against.
39    pub agent: String,
40    /// The inputs the recording was made with.
41    ///
42    /// Kept in the cassette so it is self-contained: replaying needs no
43    /// side-car file, and there is no way to pair a recording with the wrong
44    /// inputs and get a confusing digest mismatch instead of a clear one.
45    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
46    pub inputs: BTreeMap<String, Value>,
47    pub interactions: Vec<Interaction>,
48    /// Tool invocations and what they returned, in the order they happened.
49    ///
50    /// Kept in their own list rather than interleaved with the model exchanges,
51    /// because the two are matched independently: an agent may call three tools
52    /// between two `ask`s, and a single ordered stream would make each side's
53    /// position depend on the other's.
54    ///
55    /// **A recorded result contains whatever the tool returned.** That is the
56    /// review burden this format adds, and why the build-time secret scan reads
57    /// cassettes as well as source.
58    #[serde(default, skip_serializing_if = "Vec::is_empty")]
59    pub tool_calls: Vec<ToolExchange>,
60    /// Questions put to a person, and what they said, in order.
61    ///
62    /// A third list beside the other two, matched the same way, because **a
63    /// person is a third source of answers**. A question sends a prompt and gets
64    /// back a typed value; what determined the answer is the question and the
65    /// context it was asked in; asking again after either changed would be
66    /// reusing the wrong row. The shape is the same, so the machinery is.
67    ///
68    /// Kept separate rather than interleaved for the reason `toolCalls` is: the
69    /// three are matched independently, and one ordered stream would make each
70    /// side's position depend on the others'. It is also the single most
71    /// important thing about a recorded run — **which answers a machine produced
72    /// and which a person did** — and one list cannot say that.
73    ///
74    /// See [RFC-0020](../../../rfcs/0020-a-person-in-the-loop.md).
75    #[serde(default, skip_serializing_if = "Vec::is_empty")]
76    pub consultations: Vec<Consultation>,
77}
78
79/// One question, and what a person answered.
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase")]
82pub struct Consultation {
83    pub index: usize,
84    /// The IR node that asked.
85    pub node: String,
86    /// Digest of everything that determined the answer.
87    pub question_digest: String,
88    /// The question as it was put.
89    ///
90    /// Recorded beside the digest even though the digest would be enough to
91    /// match. A cassette is checked in and reviewed, and somebody reading
92    /// `"answer": "executive"` needs to see what was asked without running
93    /// anything. The same reason [`Interaction`] carries `model`.
94    pub question: String,
95    #[serde(default, skip_serializing_if = "Vec::is_empty")]
96    pub choices: Vec<String>,
97    pub answer: String,
98}
99
100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
101#[serde(rename_all = "camelCase")]
102pub struct Interaction {
103    pub index: usize,
104    /// The IR node that made the call.
105    pub node: String,
106    /// Digest of everything that determined the answer.
107    pub request_digest: String,
108    pub response_type: String,
109    /// The value returned, already typed as the caller declared.
110    pub value: Value,
111    pub usage: Usage,
112    /// The model that answered, recorded for provenance.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub model: Option<String>,
115}
116
117/// One tool invocation and what it returned.
118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
119#[serde(rename_all = "camelCase")]
120pub struct ToolExchange {
121    pub index: usize,
122    /// The IR node that made the call.
123    pub node: String,
124    /// Bare tool name, e.g. `web.search`.
125    pub tool: String,
126    /// Digest of everything that determined the answer.
127    pub invocation_digest: String,
128    /// The Ingot type the tool is declared to return.
129    pub result_type: String,
130    /// What the tool returned, already typed as the artifact declared.
131    ///
132    /// Absent when the recorded call failed: a failure has no value, and
133    /// writing `null` would make "returned nothing" and "did not run"
134    /// indistinguishable.
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub value: Option<Value>,
137    /// The failure the tool produced, when it produced one.
138    ///
139    /// Recorded rather than dropped: an agent's behaviour when a tool fails is
140    /// exactly the behaviour worth having a test for, and a cassette that could
141    /// only record success would be a cassette of the happy path.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub error: Option<String>,
144}
145
146impl Cassette {
147    pub fn new(agent: impl Into<String>) -> Cassette {
148        Cassette {
149            cassette_version: CASSETTE_VERSION.to_string(),
150            agent: agent.into(),
151            inputs: BTreeMap::new(),
152            interactions: Vec::new(),
153            tool_calls: Vec::new(),
154            consultations: Vec::new(),
155        }
156    }
157
158    /// The canonical encoding: two-space indentation, trailing newline.
159    ///
160    /// Cassettes are checked in and reviewed, so they get the same stability
161    /// treatment as golden IR.
162    pub fn to_canonical_json(&self) -> String {
163        let mut json =
164            serde_json::to_string_pretty(self).expect("a cassette is always serializable");
165        json.push('\n');
166        json
167    }
168
169    pub fn from_json(text: &str) -> Result<Cassette, String> {
170        let cassette: Cassette =
171            serde_json::from_str(text).map_err(|error| format!("invalid cassette: {error}"))?;
172        if !SUPPORTED_CASSETTE_VERSIONS.contains(&cassette.cassette_version.as_str()) {
173            return Err(format!(
174                "cassette version `{}` is not supported by this compiler (supported: {})",
175                cassette.cassette_version,
176                SUPPORTED_CASSETTE_VERSIONS.join(", ")
177            ));
178        }
179        // A 0.1 recording that carries tool calls was written by something that
180        // did not mean 0.1. Refusing beats replaying a field the stated version
181        // does not have.
182        if cassette.cassette_version != CASSETTE_VERSION && !cassette.consultations.is_empty() {
183            return Err(format!(
184                "the cassette states version `{}` and carries consultations, which only {} has \
185                 a field for; re-record it",
186                cassette.cassette_version, CASSETTE_VERSION
187            ));
188        }
189        if cassette.cassette_version == "0.1" && !cassette.tool_calls.is_empty() {
190            return Err(
191                "the cassette states version `0.1` and carries tool calls, which 0.1 has no \
192                 field for; re-record it"
193                    .to_string(),
194            );
195        }
196        Ok(cassette)
197    }
198
199    pub fn load(path: impl AsRef<Path>) -> Result<Cassette, String> {
200        let path = path.as_ref();
201        let text = std::fs::read_to_string(path)
202            .map_err(|error| format!("cannot read {}: {error}", path.display()))?;
203        Cassette::from_json(&text)
204    }
205
206    pub fn save(&self, path: impl AsRef<Path>) -> Result<(), String> {
207        let path = path.as_ref();
208        if let Some(parent) = path.parent() {
209            if !parent.as_os_str().is_empty() {
210                std::fs::create_dir_all(parent)
211                    .map_err(|error| format!("cannot create {}: {error}", parent.display()))?;
212            }
213        }
214        std::fs::write(path, self.to_canonical_json())
215            .map_err(|error| format!("cannot write {}: {error}", path.display()))
216    }
217}
218
219/// Serves recorded answers in order.
220pub struct ReplayProvider {
221    cassette: Cassette,
222    position: usize,
223    /// When false, a digest mismatch is a warning rather than an error. Only
224    /// used by tooling that deliberately replays against edited sources.
225    strict: bool,
226}
227
228impl ReplayProvider {
229    pub fn new(cassette: Cassette) -> ReplayProvider {
230        ReplayProvider {
231            cassette,
232            position: 0,
233            strict: true,
234        }
235    }
236
237    pub fn lenient(mut self) -> ReplayProvider {
238        self.strict = false;
239        self
240    }
241
242    /// Start at `played` rather than at the beginning.
243    ///
244    /// For continuing an interrupted run: interactions are matched by position,
245    /// so the second half has to pick up where the first stopped. Anything past
246    /// the end leaves the provider with nothing to answer, which is the same
247    /// failure a cassette that ran out already gives.
248    pub fn skipping(mut self, played: usize) -> ReplayProvider {
249        self.position = played.min(self.cassette.interactions.len());
250        self
251    }
252
253    /// Interactions recorded but never played back.
254    pub fn remaining(&self) -> usize {
255        self.cassette
256            .interactions
257            .len()
258            .saturating_sub(self.position)
259    }
260}
261
262impl ModelProvider for ReplayProvider {
263    fn name(&self) -> &str {
264        "replay"
265    }
266
267    fn complete(
268        &mut self,
269        request: &CompletionRequest,
270    ) -> Result<CompletionResponse, ProviderError> {
271        let Some(interaction) = self.cassette.interactions.get(self.position) else {
272            return Err(ProviderError::Cassette(format!(
273                "the cassette has {} interaction(s) but the run asked for another at node `{}`; \
274                 re-record it",
275                self.cassette.interactions.len(),
276                request.node
277            )));
278        };
279        self.position += 1;
280
281        // Checked before the digest: a changed response type is a specific,
282        // nameable difference, and saying so beats the generic "something in
283        // the request changed" that the digest can offer.
284        if interaction.response_type != request.response_type {
285            return Err(ProviderError::Cassette(format!(
286                "interaction {} recorded a `{}` response but node `{}` now asks for `{}`; \
287                 re-record the cassette",
288                interaction.index, interaction.response_type, request.node, request.response_type
289            )));
290        }
291
292        if self.strict && interaction.request_digest != request.digest() {
293            return Err(ProviderError::Cassette(format!(
294                "interaction {} was recorded for a different request at node `{}`. \
295                 The prompt or its context changed since recording — \
296                 re-record the cassette and review the diff.",
297                interaction.index, request.node
298            )));
299        }
300
301        Ok(CompletionResponse {
302            value: interaction.value.clone(),
303            usage: interaction.usage,
304            model: interaction
305                .model
306                .clone()
307                .unwrap_or_else(|| "replay".to_string()),
308        })
309    }
310}
311
312/// Wraps another provider and records everything it answers.
313pub struct RecordingProvider<P: ModelProvider> {
314    inner: P,
315    cassette: Cassette,
316}
317
318impl<P: ModelProvider> RecordingProvider<P> {
319    pub fn new(inner: P, agent: impl Into<String>) -> RecordingProvider<P> {
320        RecordingProvider {
321            inner,
322            cassette: Cassette::new(agent),
323        }
324    }
325
326    /// Record the inputs alongside the interactions.
327    pub fn with_inputs(mut self, inputs: BTreeMap<String, Value>) -> RecordingProvider<P> {
328        self.cassette.inputs = inputs;
329        self
330    }
331
332    pub fn finish(self) -> Cassette {
333        self.cassette
334    }
335
336    /// What goes on the tape, whichever transport delivered it.
337    ///
338    /// A cassette records the answer, never how it arrived: two runs of the
339    /// same artifact, one streamed and one not, must produce the same tape, or
340    /// a recording would pin a property of the connection.
341    fn record(&mut self, request: &CompletionRequest, response: &CompletionResponse) {
342        self.cassette.interactions.push(Interaction {
343            index: self.cassette.interactions.len(),
344            node: request.node.clone(),
345            request_digest: request.digest(),
346            response_type: request.response_type.clone(),
347            value: response.value.clone(),
348            usage: response.usage,
349            model: Some(response.model.clone()),
350        });
351    }
352}
353
354impl<P: ModelProvider> ModelProvider for RecordingProvider<P> {
355    fn name(&self) -> &str {
356        self.inner.name()
357    }
358
359    fn complete(
360        &mut self,
361        request: &CompletionRequest,
362    ) -> Result<CompletionResponse, ProviderError> {
363        let response = self.inner.complete(request)?;
364        self.record(request, &response);
365        Ok(response)
366    }
367
368    fn streams(&self) -> bool {
369        self.inner.streams()
370    }
371
372    fn complete_streaming(
373        &mut self,
374        request: &CompletionRequest,
375        on_delta: crate::provider::DeltaSink<'_>,
376    ) -> Result<CompletionResponse, ProviderError> {
377        let response = self.inner.complete_streaming(request, on_delta)?;
378        self.record(request, &response);
379        Ok(response)
380    }
381}
382
383/// A stable digest of everything that determines what a tool returns.
384///
385/// The agent is in it because two agents in one program deliberately hold
386/// different policies, so the same call from a different agent is a different
387/// call. Effects are not: they say what the call is allowed to do, not what it
388/// answers.
389pub fn invocation_digest(invocation: &ToolInvocation) -> String {
390    let mut hasher = Sha256::new();
391    hasher.update(invocation.agent.as_bytes());
392    hasher.update([0]);
393    hasher.update(invocation.reference.as_bytes());
394    hasher.update([0]);
395    hasher.update(invocation.result_type.as_bytes());
396    // A BTreeMap iterates in key order and `to_string` sorts object keys, so
397    // this is stable across runs and across machines.
398    for (name, value) in &invocation.arguments {
399        hasher.update([0]);
400        hasher.update(name.as_bytes());
401        hasher.update([0]);
402        hasher.update(value.to_string().as_bytes());
403    }
404    format!("{:x}", hasher.finalize())
405}
406
407/// A stable digest of everything that determined what a person answered.
408///
409/// The question, the choices, and the context the run showed them. Context is in
410/// it for the reason it is an argument at all: a person's answer can depend on
411/// what they were shown, so two runs with identical question text can deserve
412/// different answers — and a digest that ignored it would replay the first into
413/// the second without noticing.
414pub fn question_digest(request: &ConsultRequest) -> String {
415    let mut hasher = Sha256::new();
416    hasher.update(request.question.as_bytes());
417    for choice in &request.choices {
418        hasher.update([0]);
419        hasher.update(choice.as_bytes());
420    }
421    for (name, value) in &request.context {
422        hasher.update([0]);
423        hasher.update(name.as_bytes());
424        hasher.update([0]);
425        hasher.update(value.to_string().as_bytes());
426    }
427    format!("{:x}", hasher.finalize())
428}
429
430/// Serves recorded answers in order, asking nobody.
431///
432/// The bargain the other two replays strike, for the half that costs most to
433/// re-record: **a consultation in CI is served from the recording, never asked.**
434/// That is not a workaround — it is the identical arrangement already in place
435/// for the model and for the tools.
436pub struct ReplayInterlocutor {
437    consultations: Vec<Consultation>,
438    position: usize,
439    strict: bool,
440}
441
442impl ReplayInterlocutor {
443    pub fn new(consultations: Vec<Consultation>) -> ReplayInterlocutor {
444        ReplayInterlocutor {
445            consultations,
446            position: 0,
447            strict: true,
448        }
449    }
450
451    pub fn lenient(mut self) -> ReplayInterlocutor {
452        self.strict = false;
453        self
454    }
455
456    /// Start at `played`, for continuing an interrupted run.
457    pub fn skipping(mut self, played: usize) -> ReplayInterlocutor {
458        self.position = played.min(self.consultations.len());
459        self
460    }
461
462    /// Answers recorded but never played back.
463    pub fn remaining(&self) -> usize {
464        self.consultations.len().saturating_sub(self.position)
465    }
466}
467
468impl Interlocutor for ReplayInterlocutor {
469    /// A recorded run's gates were decided when it was recorded, and a cassette
470    /// does not carry them. Approving is what lets a recorded run replay at all,
471    /// and it decides nothing new: the effect already happened once, under a
472    /// person who said yes.
473    fn approve(&mut self, _request: &ApprovalRequest) -> bool {
474        true
475    }
476
477    fn consult(&mut self, request: &ConsultRequest) -> Result<String, ConsultError> {
478        let Some(recorded) = self.consultations.get(self.position) else {
479            return Err(ConsultError::NoChannel(format!(
480                "the cassette records {} consultation(s) and the run asked for another at node \
481                 `{}`; re-record it",
482                self.consultations.len(),
483                request.node
484            )));
485        };
486        self.position += 1;
487
488        if self.strict && recorded.question_digest != question_digest(request) {
489            return Err(ConsultError::Failed(format!(
490                "consultation {} was recorded for a different question at node `{}`. The \
491                 question or its context changed since recording — re-record the cassette and \
492                 review the diff. Re-recording this one means asking somebody again.",
493                recorded.index, request.node
494            )));
495        }
496        Ok(recorded.answer.clone())
497    }
498}
499
500/// Wraps a channel and records every answer a person gives.
501///
502/// Owns the channel it wraps and hands back a shared handle on the list, rather
503/// than borrowing: the wrapped channel is moved into the run, so there is no
504/// borrow left out here to read the recording from afterwards. Single-threaded
505/// by construction — the interpreter asks one question at a time and waits — so
506/// `Rc<RefCell<_>>` is the honest representation rather than a compromise, the
507/// same reading the supervisor's guest already takes.
508pub struct RecordingInterlocutor {
509    inner: HumanChannel,
510    consultations: Rc<RefCell<Vec<Consultation>>>,
511}
512
513impl RecordingInterlocutor {
514    /// The wrapper, and the list it will fill.
515    pub fn new(inner: HumanChannel) -> (RecordingInterlocutor, Rc<RefCell<Vec<Consultation>>>) {
516        let consultations = Rc::new(RefCell::new(Vec::new()));
517        (
518            RecordingInterlocutor {
519                inner,
520                consultations: Rc::clone(&consultations),
521            },
522            consultations,
523        )
524    }
525}
526
527impl Interlocutor for RecordingInterlocutor {
528    fn approve(&mut self, request: &ApprovalRequest) -> bool {
529        match &mut self.inner {
530            HumanChannel::Ask(interlocutor) => interlocutor.approve(request),
531            HumanChannel::AssumeYes => true,
532            HumanChannel::Deny => false,
533        }
534    }
535
536    fn consult(&mut self, request: &ConsultRequest) -> Result<String, ConsultError> {
537        let answer = match &mut self.inner {
538            HumanChannel::Ask(interlocutor) => interlocutor.consult(request)?,
539            HumanChannel::AssumeYes => {
540                return Err(ConsultError::NoChannel(
541                    "`--yes` approves a gate and cannot answer a question".to_string(),
542                ))
543            }
544            HumanChannel::Deny => {
545                return Err(ConsultError::NoChannel(
546                    "this run has no channel to a person".to_string(),
547                ))
548            }
549        };
550        let mut consultations = self.consultations.borrow_mut();
551        let index = consultations.len();
552        consultations.push(Consultation {
553            index,
554            node: request.node.clone(),
555            question_digest: question_digest(request),
556            question: request.question.clone(),
557            choices: request.choices.clone(),
558            answer: answer.clone(),
559        });
560        Ok(answer)
561    }
562}
563
564/// Serves recorded tool results in order.
565///
566/// The same bargain [`ReplayProvider`] strikes, for the other half of a run: no
567/// server is started, nothing is reached, and a call the recording does not
568/// match fails loudly rather than being answered from the wrong row.
569pub struct ReplayToolHost {
570    calls: Vec<ToolExchange>,
571    position: usize,
572    strict: bool,
573}
574
575impl ReplayToolHost {
576    pub fn new(calls: Vec<ToolExchange>) -> ReplayToolHost {
577        ReplayToolHost {
578            calls,
579            position: 0,
580            strict: true,
581        }
582    }
583
584    pub fn lenient(mut self) -> ReplayToolHost {
585        self.strict = false;
586        self
587    }
588
589    /// Start at `played`, for continuing an interrupted run. See
590    /// [`ReplayProvider::skipping`].
591    pub fn skipping(mut self, played: usize) -> ReplayToolHost {
592        self.position = played.min(self.calls.len());
593        self
594    }
595
596    /// Tool calls recorded but never played back.
597    pub fn remaining(&self) -> usize {
598        self.calls.len().saturating_sub(self.position)
599    }
600}
601
602impl ToolHost for ReplayToolHost {
603    fn name(&self) -> &str {
604        "replay"
605    }
606
607    /// Every tool, because what a replay can serve is decided by the recording
608    /// rather than by a table of names. A call beyond the recording is reported
609    /// by [`ToolHost::call`], where the position is known and the message can
610    /// say which call it was.
611    fn provides(&self, _tool: &str) -> bool {
612        true
613    }
614
615    fn call(&mut self, invocation: &ToolInvocation) -> Result<Value, ToolError> {
616        let Some(recorded) = self.calls.get(self.position) else {
617            return Err(ToolError::Failed(format!(
618                "the cassette records {} tool call(s) and the run asked for another: `{}`;                  re-record it",
619                self.calls.len(),
620                invocation.name
621            )));
622        };
623        self.position += 1;
624
625        // Checked before the digest: a different tool is a specific, nameable
626        // difference, and saying so beats "something about the call changed".
627        if recorded.tool != invocation.name {
628            return Err(ToolError::Failed(format!(
629                "tool call {} recorded `{}` and the run called `{}`; re-record the cassette",
630                recorded.index, recorded.tool, invocation.name
631            )));
632        }
633        if self.strict && recorded.invocation_digest != invocation_digest(invocation) {
634            return Err(ToolError::Failed(format!(
635                "tool call {} recorded different arguments for `{}`.                  The call changed since recording — re-record the cassette and review the diff.",
636                recorded.index, recorded.tool
637            )));
638        }
639
640        match (&recorded.value, &recorded.error) {
641            (Some(value), _) => Ok(value.clone()),
642            (None, Some(error)) => Err(ToolError::Failed(error.clone())),
643            (None, None) => Err(ToolError::InvalidResult(format!(
644                "tool call {} for `{}` recorded neither a value nor an error",
645                recorded.index, recorded.tool
646            ))),
647        }
648    }
649}
650
651/// Wraps another host and records everything it answers.
652pub struct RecordingTools<H: ToolHost> {
653    inner: H,
654    calls: Vec<ToolExchange>,
655}
656
657impl<H: ToolHost> RecordingTools<H> {
658    pub fn new(inner: H) -> RecordingTools<H> {
659        RecordingTools {
660            inner,
661            calls: Vec::new(),
662        }
663    }
664
665    pub fn finish(self) -> Vec<ToolExchange> {
666        self.calls
667    }
668}
669
670impl<H: ToolHost> ToolHost for RecordingTools<H> {
671    fn name(&self) -> &str {
672        self.inner.name()
673    }
674
675    fn provides(&self, tool: &str) -> bool {
676        self.inner.provides(tool)
677    }
678
679    fn call(&mut self, invocation: &ToolInvocation) -> Result<Value, ToolError> {
680        let result = self.inner.call(invocation);
681        let (value, error) = match &result {
682            Ok(value) => (Some(value.clone()), None),
683            // A failure is recorded too: how an agent behaves when a tool fails
684            // is exactly the behaviour worth testing, and a recording that could
685            // only hold successes would be a recording of the happy path.
686            Err(error) => (None, Some(error.to_string())),
687        };
688        self.calls.push(ToolExchange {
689            index: self.calls.len(),
690            node: invocation.node.clone(),
691            tool: invocation.name.clone(),
692            invocation_digest: invocation_digest(invocation),
693            result_type: invocation.result_type.clone(),
694            value,
695            error,
696        });
697        result
698    }
699}
700
701/// Answers from a fixed script, ignoring the request. Test scaffolding.
702pub struct ScriptedProvider {
703    answers: Vec<Value>,
704    position: usize,
705    usage: Usage,
706}
707
708impl ScriptedProvider {
709    pub fn new(answers: Vec<Value>) -> ScriptedProvider {
710        ScriptedProvider {
711            answers,
712            position: 0,
713            usage: Usage {
714                input_tokens: 10,
715                output_tokens: 5,
716                cache_read_tokens: 0,
717            },
718        }
719    }
720
721    pub fn with_usage(mut self, usage: Usage) -> ScriptedProvider {
722        self.usage = usage;
723        self
724    }
725
726    /// Requests served so far.
727    pub fn calls(&self) -> usize {
728        self.position
729    }
730}
731
732impl ModelProvider for ScriptedProvider {
733    fn name(&self) -> &str {
734        "scripted"
735    }
736
737    fn complete(
738        &mut self,
739        request: &CompletionRequest,
740    ) -> Result<CompletionResponse, ProviderError> {
741        let Some(value) = self.answers.get(self.position).cloned() else {
742            return Err(ProviderError::Cassette(format!(
743                "the script has {} answer(s) but node `{}` asked for another",
744                self.answers.len(),
745                request.node
746            )));
747        };
748        self.position += 1;
749        Ok(CompletionResponse {
750            value,
751            usage: self.usage,
752            model: "scripted".to_string(),
753        })
754    }
755}
756
757/// Loads every cassette in a directory, keyed by file stem.
758pub fn load_directory(dir: impl AsRef<Path>) -> Result<BTreeMap<String, Cassette>, String> {
759    let dir = dir.as_ref();
760    let mut cassettes = BTreeMap::new();
761    let entries = std::fs::read_dir(dir)
762        .map_err(|error| format!("cannot read {}: {error}", dir.display()))?;
763    for entry in entries.flatten() {
764        let path = entry.path();
765        if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
766            continue;
767        }
768        let name = path
769            .file_stem()
770            .and_then(|stem| stem.to_str())
771            .unwrap_or_default()
772            .to_string();
773        cassettes.insert(name, Cassette::load(&path)?);
774    }
775    Ok(cassettes)
776}
777
778#[cfg(test)]
779mod tests {
780    use super::*;
781    use crate::schema::ResponseShape;
782    use serde_json::json;
783
784    fn request(node: &str, prompt: &str) -> CompletionRequest {
785        CompletionRequest {
786            node: node.into(),
787            model: crate::provider::ModelSelection::Default,
788            system: None,
789            prompt: prompt.into(),
790            context: Vec::new(),
791            response_type: "markdown".into(),
792            shape: ResponseShape::Prose,
793            max_tokens: 1024,
794        }
795    }
796
797    fn recorded() -> Cassette {
798        let mut provider = RecordingProvider::new(
799            ScriptedProvider::new(vec![json!("first"), json!("second")]),
800            "test.Agent",
801        );
802        provider.complete(&request("n0", "one")).unwrap();
803        provider.complete(&request("n1", "two")).unwrap();
804        provider.finish()
805    }
806
807    #[test]
808    fn a_cassette_round_trips_through_canonical_json() {
809        let cassette = recorded();
810        let parsed = Cassette::from_json(&cassette.to_canonical_json()).unwrap();
811        assert_eq!(parsed, cassette);
812    }
813
814    #[test]
815    fn canonical_json_ends_with_a_newline() {
816        assert!(recorded().to_canonical_json().ends_with("}\n"));
817    }
818
819    #[test]
820    fn replay_serves_recorded_answers_in_order() {
821        let mut provider = ReplayProvider::new(recorded());
822        assert_eq!(
823            provider.complete(&request("n0", "one")).unwrap().value,
824            json!("first")
825        );
826        assert_eq!(
827            provider.complete(&request("n1", "two")).unwrap().value,
828            json!("second")
829        );
830        assert_eq!(provider.remaining(), 0);
831    }
832
833    #[test]
834    fn replay_rejects_a_changed_prompt() {
835        let mut provider = ReplayProvider::new(recorded());
836        let error = provider
837            .complete(&request("n0", "a different prompt"))
838            .unwrap_err();
839        let message = error.to_string();
840        assert!(
841            message.contains("recorded for a different request"),
842            "{message}"
843        );
844        assert!(message.contains("re-record"), "{message}");
845    }
846
847    #[test]
848    fn replay_rejects_a_changed_response_type() {
849        let mut provider = ReplayProvider::new(recorded());
850        let mut changed = request("n0", "one");
851        changed.response_type = "string".into();
852        let error = provider.complete(&changed).unwrap_err();
853        assert!(
854            error.to_string().contains("recorded a `markdown` response"),
855            "{error}"
856        );
857    }
858
859    #[test]
860    fn replay_reports_an_exhausted_cassette() {
861        let mut provider = ReplayProvider::new(recorded());
862        provider.complete(&request("n0", "one")).unwrap();
863        provider.complete(&request("n1", "two")).unwrap();
864        let error = provider.complete(&request("n2", "three")).unwrap_err();
865        assert!(error.to_string().contains("asked for another"), "{error}");
866    }
867
868    // --- tool calls -------------------------------------------------------
869
870    fn tool_call(name: &str, path: &str) -> ToolInvocation {
871        ToolInvocation {
872            node: "n0".into(),
873            agent: "test.Agent".into(),
874            reference: format!("mcp:{name}"),
875            name: name.into(),
876            transport: "mcp".into(),
877            arguments: [("path".to_string(), json!(path))].into(),
878            effects: vec!["filesystem_read".into()],
879            result_type: "text".into(),
880        }
881    }
882
883    struct FixedTool(Result<Value, &'static str>);
884
885    impl ToolHost for FixedTool {
886        fn name(&self) -> &str {
887            "fixed"
888        }
889        fn provides(&self, _tool: &str) -> bool {
890            true
891        }
892        fn call(&mut self, _invocation: &ToolInvocation) -> Result<Value, ToolError> {
893            self.0
894                .clone()
895                .map_err(|error| ToolError::Failed(error.into()))
896        }
897    }
898
899    #[test]
900    fn a_recorded_tool_call_replays_without_reaching_anything() {
901        let mut recorder = RecordingTools::new(FixedTool(Ok(json!("# Sample"))));
902        recorder
903            .call(&tool_call("fs.read_file", "README.md"))
904            .unwrap();
905        let recorded = recorder.finish();
906        assert_eq!(recorded.len(), 1);
907        assert_eq!(recorded[0].tool, "fs.read_file");
908
909        let mut replay = ReplayToolHost::new(recorded);
910        assert_eq!(
911            replay
912                .call(&tool_call("fs.read_file", "README.md"))
913                .unwrap(),
914            json!("# Sample")
915        );
916        assert_eq!(replay.remaining(), 0);
917    }
918
919    #[test]
920    fn a_recorded_failure_replays_as_a_failure() {
921        // How an agent behaves when a tool fails is the behaviour most worth
922        // testing, so a recording that could only hold successes would be a
923        // recording of the happy path.
924        let mut recorder = RecordingTools::new(FixedTool(Err("no such file")));
925        assert!(recorder
926            .call(&tool_call("fs.read_file", "gone.md"))
927            .is_err());
928        let recorded = recorder.finish();
929        assert_eq!(recorded[0].value, None);
930        assert!(recorded[0]
931            .error
932            .as_deref()
933            .unwrap()
934            .contains("no such file"));
935
936        let mut replay = ReplayToolHost::new(recorded);
937        let error = replay
938            .call(&tool_call("fs.read_file", "gone.md"))
939            .unwrap_err();
940        assert!(error.to_string().contains("no such file"), "{error}");
941    }
942
943    #[test]
944    fn replay_refuses_a_call_whose_arguments_changed() {
945        let mut recorder = RecordingTools::new(FixedTool(Ok(json!("# Sample"))));
946        recorder
947            .call(&tool_call("fs.read_file", "README.md"))
948            .unwrap();
949        let mut replay = ReplayToolHost::new(recorder.finish());
950
951        let error = replay
952            .call(&tool_call("fs.read_file", "notes.md"))
953            .unwrap_err();
954        assert!(error.to_string().contains("re-record"), "{error}");
955    }
956
957    #[test]
958    fn replay_refuses_a_different_tool_by_name() {
959        let mut recorder = RecordingTools::new(FixedTool(Ok(json!("# Sample"))));
960        recorder
961            .call(&tool_call("fs.read_file", "README.md"))
962            .unwrap();
963        let mut replay = ReplayToolHost::new(recorder.finish());
964
965        let error = replay
966            .call(&tool_call("fs.list_dir", "README.md"))
967            .unwrap_err();
968        let message = error.to_string();
969        assert!(message.contains("fs.read_file"), "{message}");
970        assert!(message.contains("fs.list_dir"), "{message}");
971    }
972
973    #[test]
974    fn replay_reports_a_call_beyond_the_recording() {
975        let mut replay = ReplayToolHost::new(Vec::new());
976        let error = replay
977            .call(&tool_call("fs.read_file", "README.md"))
978            .unwrap_err();
979        assert!(error.to_string().contains("asked for another"), "{error}");
980    }
981
982    #[test]
983    fn the_invocation_digest_ignores_effects_and_notices_the_agent() {
984        let base = tool_call("fs.read_file", "README.md");
985
986        let mut other_effects = tool_call("fs.read_file", "README.md");
987        other_effects.effects = vec!["filesystem_write".into()];
988        assert_eq!(
989            invocation_digest(&base),
990            invocation_digest(&other_effects),
991            "effects say what a call may do, not what it answers"
992        );
993
994        let mut other_agent = tool_call("fs.read_file", "README.md");
995        other_agent.agent = "test.Other".into();
996        assert_ne!(
997            invocation_digest(&base),
998            invocation_digest(&other_agent),
999            "two agents hold different policies, so the same call from another is another call"
1000        );
1001    }
1002
1003    #[test]
1004    fn a_zero_one_cassette_still_replays_and_a_lying_one_does_not() {
1005        let mut cassette = recorded();
1006        cassette.cassette_version = "0.1".into();
1007        let parsed = Cassette::from_json(&cassette.to_canonical_json()).unwrap();
1008        assert!(parsed.tool_calls.is_empty());
1009
1010        cassette.tool_calls.push(ToolExchange {
1011            index: 0,
1012            node: "n0".into(),
1013            tool: "fs.read_file".into(),
1014            invocation_digest: "x".into(),
1015            result_type: "text".into(),
1016            value: Some(json!("hi")),
1017            error: None,
1018        });
1019        let error = Cassette::from_json(&cassette.to_canonical_json()).unwrap_err();
1020        assert!(error.contains("re-record"), "{error}");
1021    }
1022
1023    #[test]
1024    fn a_future_cassette_version_is_rejected() {
1025        let mut cassette = recorded();
1026        cassette.cassette_version = "9.0".into();
1027        let error = Cassette::from_json(&cassette.to_canonical_json()).unwrap_err();
1028        assert!(error.contains("not supported"), "{error}");
1029    }
1030
1031    #[test]
1032    fn lenient_replay_tolerates_a_changed_prompt() {
1033        let mut provider = ReplayProvider::new(recorded()).lenient();
1034        assert!(provider.complete(&request("n0", "changed")).is_ok());
1035    }
1036}