1use 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
28pub const FIXTURE_SCHEMA_VERSION: u16 = 1;
31
32#[derive(Clone, Debug)]
33pub struct FakeFixtureHarness {
36 pub deterministic_seed: u64,
38 pub ids: DeterministicIdGenerator,
41 pub clock: DeterministicClock,
43 pub content_store: FakeContentStore,
45 pub journal_store: FakeJournalStore,
47 pub event_sink: FakeEventSink,
49 pub provider: FakeProvider,
51}
52
53impl FakeFixtureHarness {
54 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)]
77pub struct DeterministicIdGenerator {
80 seed: u64,
81 next: Cell<u64>,
82}
83
84impl DeterministicIdGenerator {
85 pub fn new(seed: u64) -> Self {
89 Self {
90 seed,
91 next: Cell::new(0),
92 }
93 }
94
95 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 pub fn next_content_ref(&self) -> ContentId {
107 ContentId::new(self.next_raw("content"))
108 }
109}
110
111#[derive(Clone, Debug)]
112pub struct DeterministicClock {
115 start_millis: u64,
116 step_millis: u64,
117 ticks: Cell<u64>,
118}
119
120impl DeterministicClock {
121 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 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)]
143pub struct FakeContentStore {
146 entries: Arc<Mutex<BTreeMap<ContentId, StoredContent>>>,
147}
148
149impl FakeContentStore {
150 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 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 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)]
192pub struct StoredContent {
195 pub media_type: String,
197 pub bytes: Vec<u8>,
200 pub redacted_summary: String,
202}
203
204#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
205pub struct StoredContentManifestEntry {
208 pub content_ref: String,
211 pub media_type: String,
213 pub byte_len: usize,
215 pub redacted_summary: String,
217}
218
219#[derive(Clone, Debug, Default)]
220pub struct FakeJournalStore {
223 records: Arc<Mutex<Vec<JournalRecord>>>,
224 fail_next_append: Arc<Mutex<Option<String>>>,
225}
226
227impl FakeJournalStore {
228 pub fn records(&self) -> Vec<JournalRecord> {
231 self.records.lock().expect("journal store lock").clone()
232 }
233
234 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 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)]
282pub struct FakeEventSink {
285 frames: Arc<Mutex<Vec<EventFrame>>>,
286}
287
288impl FakeEventSink {
289 pub fn emit(&self, frame: EventFrame) {
293 self.frames.lock().expect("event sink lock").push(frame);
294 }
295
296 pub fn frames(&self) -> Vec<EventFrame> {
299 self.frames.lock().expect("event sink lock").clone()
300 }
301
302 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)]
331pub struct FakeProvider {
334 responses: Arc<Mutex<Vec<String>>>,
335 requests: Arc<Mutex<Vec<ProviderRequest>>>,
336}
337
338impl FakeProvider {
339 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 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)]
413pub struct FixtureManifest {
416 pub schema_version: u16,
418 pub fixture_name: String,
420 pub redaction: String,
422 pub entries: Vec<FixtureManifestEntry>,
425}
426
427impl FixtureManifest {
428 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)]
442pub struct FixtureManifestEntry {
445 pub path: String,
447 pub contract: String,
449 pub schema_version: u16,
451}
452
453pub 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
465pub 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
474pub 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}