1use 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
24pub const CASSETTE_VERSION: &str = "0.3";
26
27pub 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 pub agent: String,
40 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
46 pub inputs: BTreeMap<String, Value>,
47 pub interactions: Vec<Interaction>,
48 #[serde(default, skip_serializing_if = "Vec::is_empty")]
59 pub tool_calls: Vec<ToolExchange>,
60 #[serde(default, skip_serializing_if = "Vec::is_empty")]
76 pub consultations: Vec<Consultation>,
77}
78
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase")]
82pub struct Consultation {
83 pub index: usize,
84 pub node: String,
86 pub question_digest: String,
88 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 pub node: String,
106 pub request_digest: String,
108 pub response_type: String,
109 pub value: Value,
111 pub usage: Usage,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub model: Option<String>,
115}
116
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
119#[serde(rename_all = "camelCase")]
120pub struct ToolExchange {
121 pub index: usize,
122 pub node: String,
124 pub tool: String,
126 pub invocation_digest: String,
128 pub result_type: String,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub value: Option<Value>,
137 #[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 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 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
219pub struct ReplayProvider {
221 cassette: Cassette,
222 position: usize,
223 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 pub fn skipping(mut self, played: usize) -> ReplayProvider {
249 self.position = played.min(self.cassette.interactions.len());
250 self
251 }
252
253 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 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
312pub 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 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 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
383pub 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 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
407pub 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
430pub 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 pub fn skipping(mut self, played: usize) -> ReplayInterlocutor {
458 self.position = played.min(self.consultations.len());
459 self
460 }
461
462 pub fn remaining(&self) -> usize {
464 self.consultations.len().saturating_sub(self.position)
465 }
466}
467
468impl Interlocutor for ReplayInterlocutor {
469 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
500pub struct RecordingInterlocutor {
509 inner: HumanChannel,
510 consultations: Rc<RefCell<Vec<Consultation>>>,
511}
512
513impl RecordingInterlocutor {
514 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
564pub 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 pub fn skipping(mut self, played: usize) -> ReplayToolHost {
592 self.position = played.min(self.calls.len());
593 self
594 }
595
596 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 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 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
651pub 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 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
701pub 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 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
757pub 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 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 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}