Skip to main content

agent_sdk_core/testing/
fakes.rs

1//! Reusable deterministic fakes for SDK consumers. Use this module in tests to
2//! exercise public ports without live providers, real stores, network telemetry, or
3//! product UI. Fakes may mutate only their in-memory state.
4//!
5use std::{
6    cell::Cell,
7    collections::BTreeMap,
8    fs,
9    path::Path,
10    sync::{Arc, Mutex},
11};
12
13use serde::{Deserialize, Serialize};
14use serde_json::{Map, Value};
15
16use crate::{
17    domain::{AgentError, ContentId},
18    error::{AgentErrorKind, RetryClassification},
19    events::EventFrame,
20    journal::{JOURNAL_SCHEMA_VERSION, JournalCursor, JournalRecord},
21    journal_ports::RunJournal,
22    provider::{
23        ProviderAdapter, ProviderCapabilities, ProviderRequest, ProviderResponse,
24        ProviderStopReason, ProviderUsage,
25    },
26};
27
28/// Constant value for the testing::fakes contract. Use it to keep SDK
29/// records and tests aligned on the same stable value.
30pub const FIXTURE_SCHEMA_VERSION: u16 = 1;
31
32#[derive(Clone, Debug)]
33/// In-memory fake fixture harness fixture for SDK conformance tests.
34/// Use it to script deterministic behavior in memory; any transcript or endpoint mutation is documented on the method that performs it.
35pub struct FakeFixtureHarness {
36    /// Deterministic seed used by this record or request.
37    pub deterministic_seed: u64,
38    /// Identifiers used to select or correlate ids values.
39    /// Use them for typed lookup, filtering, or lineage instead of stringly typed matching.
40    pub ids: DeterministicIdGenerator,
41    /// Clock used by this record or request.
42    pub clock: DeterministicClock,
43    /// Content store used by this record or request.
44    pub content_store: FakeContentStore,
45    /// Journal store used by this record or request.
46    pub journal_store: FakeJournalStore,
47    /// Event sink used by this record or request.
48    pub event_sink: FakeEventSink,
49    /// Provider used by this record or request.
50    pub provider: FakeProvider,
51}
52
53impl FakeFixtureHarness {
54    /// Returns this value with its seed setting replaced. The method
55    /// follows builder-style data construction and does not execute
56    /// external work.
57    pub fn with_seed(deterministic_seed: u64) -> Self {
58        Self {
59            deterministic_seed,
60            ids: DeterministicIdGenerator::new(deterministic_seed),
61            clock: DeterministicClock::new(deterministic_seed),
62            content_store: FakeContentStore::default(),
63            journal_store: FakeJournalStore::default(),
64            event_sink: FakeEventSink::default(),
65            provider: FakeProvider::default(),
66        }
67    }
68}
69
70impl Default for FakeFixtureHarness {
71    fn default() -> Self {
72        Self::with_seed(0)
73    }
74}
75
76#[derive(Clone, Debug)]
77/// In-memory deterministic id generator fixture for SDK conformance tests.
78/// Use it to script deterministic behavior in memory; any transcript or endpoint mutation is documented on the method that performs it.
79pub struct DeterministicIdGenerator {
80    seed: u64,
81    next: Cell<u64>,
82}
83
84impl DeterministicIdGenerator {
85    /// Creates a new testing::fakes value with explicit caller-provided
86    /// inputs. This constructor is data-only and performs no I/O or
87    /// external side effects.
88    pub fn new(seed: u64) -> Self {
89        Self {
90            seed,
91            next: Cell::new(0),
92        }
93    }
94
95    /// Builds the next raw value.
96    /// This is data construction and performs no I/O, journal append, event publication, or
97    /// process work.
98    pub fn next_raw(&self, prefix: &str) -> String {
99        let seq = self.next.get();
100        self.next.set(seq + 1);
101        format!("{prefix}.{:04}.{:04}", self.seed, seq)
102    }
103
104    /// Returns the next content ref currently held by this value.
105    /// This reads deterministic in-memory test state and performs no external I/O.
106    pub fn next_content_ref(&self) -> ContentId {
107        ContentId::new(self.next_raw("content"))
108    }
109}
110
111#[derive(Clone, Debug)]
112/// In-memory deterministic clock fixture for SDK conformance tests.
113/// Use it to script deterministic behavior in memory; any transcript or endpoint mutation is documented on the method that performs it.
114pub struct DeterministicClock {
115    start_millis: u64,
116    step_millis: u64,
117    ticks: Cell<u64>,
118}
119
120impl DeterministicClock {
121    /// Creates a new testing::fakes value with explicit caller-provided
122    /// inputs. This constructor is data-only and performs no I/O or
123    /// external side effects.
124    pub fn new(seed: u64) -> Self {
125        Self {
126            start_millis: seed,
127            step_millis: 1,
128            ticks: Cell::new(0),
129        }
130    }
131
132    /// Builds the next millis value.
133    /// This is data construction and performs no I/O, journal append, event publication, or
134    /// process work.
135    pub fn next_millis(&self) -> u64 {
136        let ticks = self.ticks.get();
137        self.ticks.set(ticks + 1);
138        self.start_millis + ticks * self.step_millis
139    }
140}
141
142#[derive(Clone, Debug, Default)]
143/// In-memory fake content store fixture for SDK conformance tests.
144/// Use it to script deterministic behavior in memory; any transcript or endpoint mutation is documented on the method that performs it.
145pub struct FakeContentStore {
146    entries: Arc<Mutex<BTreeMap<ContentId, StoredContent>>>,
147}
148
149impl FakeContentStore {
150    /// Builds the put text value.
151    /// This is data construction and performs no I/O, journal append, event publication, or
152    /// process work.
153    pub fn put_text(&self, content_ref: ContentId, text: impl Into<String>) {
154        self.entries.lock().expect("content store lock").insert(
155            content_ref,
156            StoredContent {
157                media_type: "text/plain; charset=utf-8".to_string(),
158                bytes: text.into().into_bytes(),
159                redacted_summary: "text content".to_string(),
160            },
161        );
162    }
163
164    /// Looks up an entry in this local store without registry or runtime work.
165    /// This reads deterministic in-memory test store state and performs no external I/O.
166    pub fn get(&self, content_ref: &ContentId) -> Option<StoredContent> {
167        self.entries
168            .lock()
169            .expect("content store lock")
170            .get(content_ref)
171            .cloned()
172    }
173
174    /// Returns the manifest currently held by this value.
175    /// This configures deterministic in-memory test state only.
176    pub fn manifest(&self) -> Vec<StoredContentManifestEntry> {
177        self.entries
178            .lock()
179            .expect("content store lock")
180            .iter()
181            .map(|(content_ref, content)| StoredContentManifestEntry {
182                content_ref: content_ref.as_str().to_string(),
183                media_type: content.media_type.clone(),
184                byte_len: content.bytes.len(),
185                redacted_summary: content.redacted_summary.clone(),
186            })
187            .collect()
188    }
189}
190
191#[derive(Clone, Debug, Eq, PartialEq)]
192/// In-memory stored content fixture for SDK conformance tests.
193/// Use it to script deterministic behavior in memory; any transcript or endpoint mutation is documented on the method that performs it.
194pub struct StoredContent {
195    /// Media type used by this record or request.
196    pub media_type: String,
197    /// Byte size or byte limit for bytes.
198    /// Use it to enforce bounded reads, writes, summaries, or parser output.
199    pub bytes: Vec<u8>,
200    /// Redacted human-readable summary safe for events, telemetry, and logs.
201    pub redacted_summary: String,
202}
203
204#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
205/// In-memory stored content manifest entry fixture for SDK conformance tests.
206/// Use it to script deterministic behavior in memory; any transcript or endpoint mutation is documented on the method that performs it.
207pub struct StoredContentManifestEntry {
208    /// Content reference where payload bytes or structured tool output are
209    /// stored.
210    pub content_ref: String,
211    /// Media type used by this record or request.
212    pub media_type: String,
213    /// Observed byte length for the source, sidecar, or extracted record.
214    pub byte_len: usize,
215    /// Redacted human-readable summary safe for events, telemetry, and logs.
216    pub redacted_summary: String,
217}
218
219#[derive(Clone, Debug, Default)]
220/// In-memory fake journal store fixture for SDK conformance tests.
221/// Use it to script deterministic behavior in memory; any transcript or endpoint mutation is documented on the method that performs it.
222pub struct FakeJournalStore {
223    records: Arc<Mutex<Vec<JournalRecord>>>,
224    fail_next_append: Arc<Mutex<Option<String>>>,
225}
226
227impl FakeJournalStore {
228    /// Returns the records currently held by this value.
229    /// This configures deterministic in-memory test state only.
230    pub fn records(&self) -> Vec<JournalRecord> {
231        self.records.lock().expect("journal store lock").clone()
232    }
233
234    /// Returns the normalized records currently held by this value.
235    /// This reads deterministic in-memory test state and performs no external I/O.
236    pub fn normalized_records(&self) -> Vec<Value> {
237        self.records()
238            .into_iter()
239            .map(|record| normalize_json_value(serde_json::to_value(record).expect("record JSON")))
240            .collect()
241    }
242
243    /// Fail next append.
244    /// This reads or mutates deterministic in-memory test state unless the method explicitly
245    /// names a fixture file.
246    pub fn fail_next_append(&self, message: impl Into<String>) {
247        *self.fail_next_append.lock().expect("journal fail lock") = Some(message.into());
248    }
249}
250
251impl RunJournal for FakeJournalStore {
252    fn append(&self, record: JournalRecord) -> Result<JournalCursor, AgentError> {
253        if let Some(message) = self
254            .fail_next_append
255            .lock()
256            .expect("journal fail lock")
257            .take()
258        {
259            return Err(AgentError::new(
260                AgentErrorKind::JournalFailure,
261                RetryClassification::RepairNeeded,
262                message,
263            ));
264        }
265        let mut records = self.records.lock().expect("journal store lock");
266        if record.journal_schema_version != JOURNAL_SCHEMA_VERSION {
267            return Err(AgentError::contract_violation(
268                "journal record schema version mismatch",
269            ));
270        }
271        if record.journal_seq != records.len() as u64 + 1 {
272            return Err(AgentError::contract_violation(
273                "journal_seq must be monotonic within fake journal",
274            ));
275        }
276        records.push(record);
277        Ok(JournalCursor::new(format!("journal.{}", records.len())))
278    }
279}
280
281#[derive(Clone, Debug, Default)]
282/// In-memory fake event sink fixture for SDK conformance tests.
283/// Use it to script deterministic behavior in memory; any transcript or endpoint mutation is documented on the method that performs it.
284pub struct FakeEventSink {
285    frames: Arc<Mutex<Vec<EventFrame>>>,
286}
287
288impl FakeEventSink {
289    /// Emit.
290    /// This reads or mutates deterministic in-memory test state unless the method explicitly
291    /// names a fixture file.
292    pub fn emit(&self, frame: EventFrame) {
293        self.frames.lock().expect("event sink lock").push(frame);
294    }
295
296    /// Returns the frames currently held by this value.
297    /// This configures deterministic in-memory test state only.
298    pub fn frames(&self) -> Vec<EventFrame> {
299        self.frames.lock().expect("event sink lock").clone()
300    }
301
302    /// Returns the normalized events currently held by this value.
303    /// This reads deterministic in-memory test state and performs no external I/O.
304    pub fn normalized_events(&self) -> Vec<Value> {
305        self.frames()
306            .into_iter()
307            .enumerate()
308            .map(|(index, frame)| normalized_event_frame(index + 1, frame))
309            .collect()
310    }
311}
312
313fn normalized_event_frame(seq: usize, frame: EventFrame) -> Value {
314    let event = frame.event;
315    let envelope = event.envelope;
316    normalize_json_value(serde_json::json!({
317        "schema_version": FIXTURE_SCHEMA_VERSION,
318        "event_seq": seq,
319        "event": {
320            "event_id": envelope.event_id.as_str(),
321            "run_id": envelope.run_id.as_str(),
322            "agent_id": envelope.agent_id.as_str(),
323            "family": format!("{:?}", envelope.event_family),
324            "kind": format!("{:?}", envelope.event_kind),
325            "privacy": format!("{:?}", envelope.privacy),
326        },
327    }))
328}
329
330#[derive(Clone, Debug)]
331/// In-memory fake provider fixture for SDK conformance tests.
332/// Use it to script deterministic behavior in memory; any transcript or endpoint mutation is documented on the method that performs it.
333pub struct FakeProvider {
334    responses: Arc<Mutex<Vec<String>>>,
335    requests: Arc<Mutex<Vec<ProviderRequest>>>,
336}
337
338impl FakeProvider {
339    /// Returns this value with its responses setting replaced. The
340    /// method follows builder-style data construction and does not
341    /// execute external work.
342    pub fn with_responses(responses: impl IntoIterator<Item = impl Into<String>>) -> Self {
343        let mut responses = responses
344            .into_iter()
345            .map(Into::into)
346            .collect::<Vec<String>>();
347        responses.reverse();
348        Self {
349            responses: Arc::new(Mutex::new(responses)),
350            requests: Arc::new(Mutex::new(Vec::new())),
351        }
352    }
353
354    /// Returns the requests currently held by this value.
355    /// This configures deterministic in-memory test state only.
356    pub fn requests(&self) -> Vec<ProviderRequest> {
357        self.requests
358            .lock()
359            .expect("provider requests lock")
360            .clone()
361    }
362
363    fn pop_response(&self) -> Result<String, AgentError> {
364        self.responses
365            .lock()
366            .expect("provider responses lock")
367            .pop()
368            .ok_or_else(|| {
369                AgentError::contract_violation("fake provider exhausted deterministic responses")
370            })
371    }
372}
373
374impl Default for FakeProvider {
375    fn default() -> Self {
376        Self::with_responses(["fake provider response"])
377    }
378}
379
380impl ProviderAdapter for FakeProvider {
381    fn capabilities(&self) -> ProviderCapabilities {
382        ProviderCapabilities::text_only("provider.fake")
383    }
384
385    fn complete(&self, request: &ProviderRequest) -> Result<ProviderResponse, AgentError> {
386        self.requests
387            .lock()
388            .expect("provider requests lock")
389            .push(request.clone());
390
391        let output_text = self.pop_response()?;
392        let input_tokens = request
393            .messages
394            .iter()
395            .map(|message| message.content.split_whitespace().count() as u32)
396            .sum::<u32>();
397        let output_tokens = output_text.split_whitespace().count() as u32;
398
399        Ok(ProviderResponse {
400            schema_version: ProviderResponse::SCHEMA_VERSION,
401            output_text,
402            stop_reason: ProviderStopReason::EndTurn,
403            usage: Some(ProviderUsage {
404                input_tokens: Some(input_tokens),
405                output_tokens: Some(output_tokens),
406                total_tokens: Some(input_tokens + output_tokens),
407            }),
408        })
409    }
410}
411
412#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
413/// In-memory fixture manifest fixture for SDK conformance tests.
414/// Use it to script deterministic behavior in memory; any transcript or endpoint mutation is documented on the method that performs it.
415pub struct FixtureManifest {
416    /// Wire schema version used for compatibility checks.
417    pub schema_version: u16,
418    /// Fixture name used by this record or request.
419    pub fixture_name: String,
420    /// Redaction used by this record or request.
421    pub redaction: String,
422    /// Bounded entries included in this record. Limits and truncation are
423    /// represented by companion metadata when applicable.
424    pub entries: Vec<FixtureManifestEntry>,
425}
426
427impl FixtureManifest {
428    /// Creates a new testing::fakes value with explicit caller-provided
429    /// inputs. This constructor is data-only and performs no I/O or
430    /// external side effects.
431    pub fn new(fixture_name: impl Into<String>) -> Self {
432        Self {
433            schema_version: FIXTURE_SCHEMA_VERSION,
434            fixture_name: fixture_name.into(),
435            redaction: "golden fixtures contain redacted summaries or metadata unless a later contract explicitly opts into raw content".to_string(),
436            entries: Vec::new(),
437        }
438    }
439}
440
441#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
442/// In-memory fixture manifest entry fixture for SDK conformance tests.
443/// Use it to script deterministic behavior in memory; any transcript or endpoint mutation is documented on the method that performs it.
444pub struct FixtureManifestEntry {
445    /// Workspace-relative or resource path selected by the request or result.
446    pub path: String,
447    /// Contract used by this record or request.
448    pub contract: String,
449    /// Wire schema version used for compatibility checks.
450    pub schema_version: u16,
451}
452
453/// Write fixture.
454/// This writes normalized JSON to the caller-provided fixture path on disk.
455pub fn write_fixture(path: impl AsRef<Path>, value: &Value) -> Result<(), AgentError> {
456    let path = path.as_ref();
457    if let Some(parent) = path.parent() {
458        fs::create_dir_all(parent).map_err(io_error)?;
459    }
460    let json =
461        serde_json::to_string_pretty(&normalize_json_value(value.clone())).map_err(serde_error)?;
462    fs::write(path, format!("{json}\n")).map_err(io_error)
463}
464
465/// Read fixture.
466/// This reads and parses normalized JSON from the caller-provided fixture path on disk.
467pub fn read_fixture(path: impl AsRef<Path>) -> Result<Value, AgentError> {
468    let json = fs::read_to_string(path).map_err(io_error)?;
469    serde_json::from_str::<Value>(&json)
470        .map(normalize_json_value)
471        .map_err(serde_error)
472}
473
474/// Returns normalize json value for the current value.
475/// This is a read-only or data-construction helper unless the method body explicitly calls a
476/// port or store.
477pub fn normalize_json_value(value: Value) -> Value {
478    match value {
479        Value::Array(items) => Value::Array(items.into_iter().map(normalize_json_value).collect()),
480        Value::Object(fields) => {
481            let mut normalized = Map::new();
482            let mut sorted = BTreeMap::new();
483            for (key, value) in fields {
484                sorted.insert(key, value);
485            }
486            for (key, value) in sorted {
487                normalized.insert(key, normalize_json_value(value));
488            }
489            Value::Object(normalized)
490        }
491        scalar => scalar,
492    }
493}
494
495fn io_error(error: std::io::Error) -> AgentError {
496    AgentError::contract_violation(format!("fixture I/O failed: {error}"))
497}
498
499fn serde_error(error: serde_json::Error) -> AgentError {
500    AgentError::contract_violation(format!("fixture JSON failed: {error}"))
501}