Skip to main content

klieo_core/
test_utils.rs

1//! In-process fakes used in `klieo-core`'s own tests and exported under
2//! the `test-utils` feature for downstream crates.
3//!
4//! These impls trade fidelity for simplicity:
5//! - `FakeLlmClient` returns a programmable script of responses.
6//! - `FakeToolInvoker` returns a programmable map of `name -> handler`.
7//! - In-memory memories store everything in `tokio::sync::Mutex`-guarded
8//!   `HashMap`s.
9//! - `noop_bus()` returns `Arc<dyn …>` impls that succeed without doing
10//!   anything (sufficient when an agent's logic ignores the bus).
11
12#![cfg(any(test, feature = "test-utils"))]
13#![allow(missing_docs)] // test fakes don't need full rustdoc coverage
14
15use crate::agent::AgentContext;
16use crate::bus::{
17    ClaimHandleImpl, ClaimedJob, Headers, Job, JobQueue, KvEntry, KvStore, Lease, LeaseImpl, Msg,
18    MsgStream, Pubsub, RequestReply, Revision,
19};
20use crate::error::{BusError, LlmError, MemoryError, ToolError};
21use crate::ids::{DurableName, FactId, JobId, RunId, ThreadId};
22use crate::llm::{
23    Capabilities, ChatChunk, ChatRequest, ChatResponse, ChunkStream, Embedding, FinishReason,
24    LlmClient, Message, ToolCall,
25};
26use crate::memory::{
27    Episode, EpisodicMemory, Fact, LongTermMemory, RunFilter, RunSummary, Scope, ShortTermMemory,
28};
29use crate::runtime::{run_steps, RunOptions};
30use crate::tool::{ToolCtx, ToolInvoker};
31use async_trait::async_trait;
32use bytes::Bytes;
33use chrono::Utc;
34use futures_core::Stream;
35use std::collections::HashMap;
36use std::pin::Pin;
37use std::sync::Arc;
38use std::task::{Context, Poll};
39use std::time::Duration;
40use tokio::sync::Mutex;
41use tokio_util::sync::CancellationToken;
42
43// ---- LlmClient fake ----
44
45/// One programmed LLM response.
46#[derive(Clone)]
47pub enum FakeLlmStep {
48    /// Return content text and stop.
49    Text(String),
50    /// Return tool calls and let the runtime dispatch them.
51    ToolCalls(Vec<ToolCall>),
52}
53
54/// One programmed streaming response. Used by [`FakeLlmClient::stream`].
55pub enum FakeStreamStep {
56    /// Successfully open a stream that yields the supplied chunks (each
57    /// `Result` is one item the underlying stream will produce, in order).
58    Chunks(Vec<Result<ChatChunk, LlmError>>),
59    /// Fail at stream-initiation time with the supplied error. The
60    /// `Option` lets the fake take the error once when the call lands;
61    /// prefer [`FakeStreamStep::init_err`] which hides the `Option`.
62    InitErr(Option<LlmError>),
63}
64
65impl FakeStreamStep {
66    /// Construct an `InitErr` step from a single [`LlmError`]. The
67    /// internal storage stays `Option<LlmError>` so the fake can take
68    /// the error exactly once at call time, but callers don't need to
69    /// wrap manually.
70    pub fn init_err(e: LlmError) -> Self {
71        Self::InitErr(Some(e))
72    }
73}
74
75#[derive(Default)]
76pub struct FakeLlmClient {
77    name: String,
78    caps: Capabilities,
79    script: Mutex<std::collections::VecDeque<FakeLlmStep>>,
80    stream_script: Mutex<std::collections::VecDeque<FakeStreamStep>>,
81    stream_calls: std::sync::atomic::AtomicU32,
82}
83
84impl FakeLlmClient {
85    pub fn new(name: impl Into<String>) -> Self {
86        Self {
87            name: name.into(),
88            caps: Capabilities {
89                tool_calling: true,
90                streaming: false,
91                structured_output: false,
92                embeddings: false,
93                max_context_tokens: 32_000,
94                vision: false,
95            },
96            script: Mutex::new(std::collections::VecDeque::new()),
97            stream_script: Mutex::new(std::collections::VecDeque::new()),
98            stream_calls: std::sync::atomic::AtomicU32::new(0),
99        }
100    }
101
102    pub fn with_steps(self, steps: Vec<FakeLlmStep>) -> Self {
103        let mut new_q = std::collections::VecDeque::new();
104        new_q.extend(steps);
105        Self {
106            script: Mutex::new(new_q),
107            ..self
108        }
109    }
110
111    /// Program the script consumed by [`LlmClient::stream`]. Each call to
112    /// `stream` pops one [`FakeStreamStep`] off the front.
113    pub fn with_stream_steps(self, steps: Vec<FakeStreamStep>) -> Self {
114        let mut new_q = std::collections::VecDeque::new();
115        new_q.extend(steps);
116        Self {
117            stream_script: Mutex::new(new_q),
118            ..self
119        }
120    }
121
122    /// Number of times [`LlmClient::stream`] has been called.
123    pub fn stream_call_count(&self) -> u32 {
124        self.stream_calls.load(std::sync::atomic::Ordering::SeqCst)
125    }
126}
127
128#[async_trait]
129impl LlmClient for FakeLlmClient {
130    fn name(&self) -> &str {
131        &self.name
132    }
133
134    fn capabilities(&self) -> &Capabilities {
135        &self.caps
136    }
137
138    async fn complete(&self, _req: ChatRequest) -> Result<ChatResponse, LlmError> {
139        let mut q = self.script.lock().await;
140        let step = q
141            .pop_front()
142            .ok_or_else(|| LlmError::BadRequest("FakeLlmClient: script exhausted".into()))?;
143        let (msg, finish) = match step {
144            FakeLlmStep::Text(s) => (
145                Message {
146                    role: crate::llm::Role::Assistant,
147                    content: s,
148                    tool_calls: vec![],
149                    tool_call_id: None,
150                },
151                FinishReason::Stop,
152            ),
153            FakeLlmStep::ToolCalls(calls) => (
154                Message {
155                    role: crate::llm::Role::Assistant,
156                    content: String::new(),
157                    tool_calls: calls,
158                    tool_call_id: None,
159                },
160                FinishReason::ToolCalls,
161            ),
162        };
163        Ok(ChatResponse::new(
164            msg,
165            Default::default(),
166            finish,
167            self.name.clone(),
168        ))
169    }
170
171    async fn stream(&self, _req: ChatRequest) -> Result<ChunkStream, LlmError> {
172        self.stream_calls
173            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
174        let mut q = self.stream_script.lock().await;
175        let step = q
176            .pop_front()
177            .ok_or_else(|| LlmError::Unsupported("streaming".into()))?;
178        match step {
179            FakeStreamStep::Chunks(items) => Ok(Box::pin(tokio_stream::iter(items))),
180            FakeStreamStep::InitErr(mut slot) => {
181                let e = slot
182                    .take()
183                    .unwrap_or_else(|| LlmError::Server("fake init err already taken".into()));
184                Err(e)
185            }
186        }
187    }
188
189    async fn embed(&self, _texts: &[String]) -> Result<Vec<Embedding>, LlmError> {
190        Err(LlmError::Unsupported("embeddings".into()))
191    }
192}
193
194/// Deterministic [`LlmClient`] that returns a fixed list of assistant
195/// replies by call index, clamping to the last entry once the list is
196/// exhausted so multi-step runs never error. Simpler than
197/// [`FakeLlmClient`] when a test or example just needs scripted text
198/// turns; shared by the `hitl-agent`, `mcp-workflow-server`, and
199/// `reference-insurance-app` examples.
200pub struct ScriptedLlm {
201    capabilities: Capabilities,
202    replies: Vec<String>,
203    calls: std::sync::atomic::AtomicUsize,
204}
205
206impl ScriptedLlm {
207    /// Build from the ordered assistant replies returned per `complete`.
208    pub fn new(replies: Vec<String>) -> Self {
209        Self {
210            capabilities: Capabilities::default(),
211            replies,
212            calls: std::sync::atomic::AtomicUsize::new(0),
213        }
214    }
215}
216
217#[async_trait]
218impl LlmClient for ScriptedLlm {
219    fn name(&self) -> &str {
220        "scripted"
221    }
222    fn capabilities(&self) -> &Capabilities {
223        &self.capabilities
224    }
225    async fn complete(&self, _req: ChatRequest) -> Result<ChatResponse, LlmError> {
226        let index = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
227        let content = self
228            .replies
229            .get(index)
230            .or_else(|| self.replies.last())
231            .cloned()
232            .unwrap_or_default();
233        Ok(ChatResponse::new(
234            Message {
235                role: crate::llm::Role::Assistant,
236                content,
237                tool_calls: vec![],
238                tool_call_id: None,
239            },
240            Default::default(),
241            FinishReason::Stop,
242            "scripted",
243        ))
244    }
245    async fn stream(&self, _req: ChatRequest) -> Result<ChunkStream, LlmError> {
246        Err(LlmError::Unsupported(
247            "scripted llm has no streaming".into(),
248        ))
249    }
250    async fn embed(&self, _texts: &[String]) -> Result<Vec<Embedding>, LlmError> {
251        Err(LlmError::Unsupported(
252            "scripted llm has no embeddings".into(),
253        ))
254    }
255}
256
257// ---- ToolInvoker fake ----
258
259type ToolHandler =
260    Arc<dyn Fn(serde_json::Value) -> Result<serde_json::Value, ToolError> + Send + Sync>;
261
262#[derive(Default)]
263pub struct FakeToolInvoker {
264    handlers: HashMap<String, ToolHandler>,
265    catalogue: Vec<crate::llm::ToolDef>,
266    idempotent: std::collections::HashSet<String>,
267    redacting: std::collections::HashSet<String>,
268}
269
270impl FakeToolInvoker {
271    pub fn new() -> Self {
272        Self::default()
273    }
274
275    pub fn with_tool<F>(mut self, name: &str, description: &str, handler: F) -> Self
276    where
277        F: Fn(serde_json::Value) -> Result<serde_json::Value, ToolError> + Send + Sync + 'static,
278    {
279        self.handlers.insert(name.to_string(), Arc::new(handler));
280        self.catalogue.push(crate::llm::ToolDef {
281            name: name.to_string(),
282            description: description.to_string(),
283            json_schema: serde_json::json!({"type": "object"}),
284        });
285        self
286    }
287
288    /// Like [`with_tool`](Self::with_tool), but the tool reports as idempotent —
289    /// the resume path may re-dispatch it without operator intervention.
290    pub fn with_idempotent_tool<F>(mut self, name: &str, description: &str, handler: F) -> Self
291    where
292        F: Fn(serde_json::Value) -> Result<serde_json::Value, ToolError> + Send + Sync + 'static,
293    {
294        self.idempotent.insert(name.to_string());
295        self.with_tool(name, description, handler)
296    }
297
298    /// Like [`with_tool`](Self::with_tool), but the tool reports as
299    /// audit-redacting — dispatch records a redacted projection of its
300    /// args/result instead of the raw bytes.
301    pub fn with_redacting_tool<F>(mut self, name: &str, description: &str, handler: F) -> Self
302    where
303        F: Fn(serde_json::Value) -> Result<serde_json::Value, ToolError> + Send + Sync + 'static,
304    {
305        self.redacting.insert(name.to_string());
306        self.with_tool(name, description, handler)
307    }
308}
309
310#[async_trait]
311impl ToolInvoker for FakeToolInvoker {
312    async fn invoke(
313        &self,
314        name: &str,
315        args: serde_json::Value,
316        _ctx: ToolCtx,
317    ) -> Result<serde_json::Value, ToolError> {
318        let h = self
319            .handlers
320            .get(name)
321            .ok_or_else(|| ToolError::UnknownTool(name.into()))?;
322        h(args)
323    }
324
325    fn catalogue(&self) -> Vec<crate::llm::ToolDef> {
326        self.catalogue.clone()
327    }
328
329    fn is_tool_idempotent(&self, name: &str) -> bool {
330        self.idempotent.contains(name)
331    }
332
333    fn tool_redacts_audit(&self, name: &str) -> bool {
334        self.redacting.contains(name)
335    }
336}
337
338// ---- ShortTermMemory fake ----
339
340#[derive(Default)]
341pub struct InMemoryShortTerm {
342    inner: Mutex<HashMap<String, Vec<Message>>>,
343}
344
345#[async_trait]
346impl ShortTermMemory for InMemoryShortTerm {
347    async fn append(&self, thread: ThreadId, msg: Message) -> Result<(), MemoryError> {
348        self.inner
349            .lock()
350            .await
351            .entry(thread.0)
352            .or_default()
353            .push(msg);
354        Ok(())
355    }
356
357    async fn load(
358        &self,
359        thread: ThreadId,
360        _max_tokens: usize,
361    ) -> Result<Vec<Message>, MemoryError> {
362        Ok(self
363            .inner
364            .lock()
365            .await
366            .get(&thread.0)
367            .cloned()
368            .unwrap_or_default())
369    }
370
371    async fn clear(&self, thread: ThreadId) -> Result<(), MemoryError> {
372        self.inner.lock().await.remove(&thread.0);
373        Ok(())
374    }
375}
376
377// ---- LongTermMemory fake ----
378
379#[derive(Default)]
380pub struct InMemoryLongTerm {
381    inner: Mutex<Vec<(FactId, Scope, Fact)>>,
382    counter: Mutex<u64>,
383}
384
385#[async_trait]
386impl LongTermMemory for InMemoryLongTerm {
387    async fn remember(&self, scope: Scope, fact: Fact) -> Result<FactId, MemoryError> {
388        let mut c = self.counter.lock().await;
389        *c += 1;
390        let id = FactId(format!("fake-{}", *c));
391        self.inner.lock().await.push((id.clone(), scope, fact));
392        Ok(id)
393    }
394
395    async fn recall(&self, scope: Scope, query: &str, k: usize) -> Result<Vec<Fact>, MemoryError> {
396        let q = query.to_lowercase();
397        Ok(self
398            .inner
399            .lock()
400            .await
401            .iter()
402            .filter(|(_, s, _)| *s == scope)
403            .filter(|(_, _, f)| f.text.to_lowercase().contains(&q))
404            .take(k)
405            .map(|(_, _, f)| f.clone())
406            .collect())
407    }
408
409    async fn forget(&self, id: FactId) -> Result<(), MemoryError> {
410        self.inner.lock().await.retain(|(i, _, _)| i != &id);
411        Ok(())
412    }
413}
414
415// ---- EpisodicMemory fake ----
416
417#[derive(Default)]
418pub struct InMemoryEpisodic {
419    inner: Mutex<HashMap<RunId, Vec<Episode>>>,
420}
421
422#[async_trait]
423impl EpisodicMemory for InMemoryEpisodic {
424    async fn record(&self, run: RunId, event: Episode) -> Result<(), MemoryError> {
425        self.inner.lock().await.entry(run).or_default().push(event);
426        Ok(())
427    }
428
429    async fn replay(&self, run: RunId) -> Result<Vec<Episode>, MemoryError> {
430        Ok(self
431            .inner
432            .lock()
433            .await
434            .get(&run)
435            .cloned()
436            .unwrap_or_default())
437    }
438
439    async fn list_runs(&self, _filter: RunFilter) -> Result<Vec<RunSummary>, MemoryError> {
440        let g = self.inner.lock().await;
441        Ok(g.iter()
442            .map(|(id, eps)| RunSummary {
443                run_id: *id,
444                agent: String::new(),
445                started_at: Utc::now(),
446                finished_at: None,
447                episode_count: eps.len() as u32,
448            })
449            .collect())
450    }
451}
452
453// ---- No-op bus impls ----
454
455/// An empty stream that never yields messages.
456struct EmptyStream;
457
458impl Stream for EmptyStream {
459    type Item = Result<Msg, BusError>;
460
461    fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
462        Poll::Ready(None)
463    }
464}
465
466pub struct NoopPubsub;
467
468#[async_trait]
469impl Pubsub for NoopPubsub {
470    async fn publish(
471        &self,
472        _subject: &str,
473        _payload: Bytes,
474        _headers: Headers,
475    ) -> Result<(), BusError> {
476        Ok(())
477    }
478
479    async fn subscribe(
480        &self,
481        _subject: &str,
482        _durable: DurableName,
483    ) -> Result<MsgStream, BusError> {
484        let s = EmptyStream;
485        Ok(Box::pin(s))
486    }
487}
488
489pub struct NoopRequestReply;
490
491#[async_trait]
492impl RequestReply for NoopRequestReply {
493    async fn request(
494        &self,
495        _subject: &str,
496        _payload: Bytes,
497        _timeout: Duration,
498    ) -> Result<Bytes, BusError> {
499        Err(BusError::NotFound("noop bus".into()))
500    }
501}
502
503pub struct NoopKv;
504
505struct NoopLease;
506#[async_trait]
507impl LeaseImpl for NoopLease {
508    async fn heartbeat(&self) -> Result<(), BusError> {
509        Ok(())
510    }
511}
512
513#[async_trait]
514impl KvStore for NoopKv {
515    async fn get(&self, _b: &str, _k: &str) -> Result<Option<KvEntry>, BusError> {
516        Ok(None)
517    }
518
519    async fn put(&self, _b: &str, _k: &str, _v: Bytes) -> Result<Revision, BusError> {
520        Ok(1)
521    }
522
523    async fn cas(
524        &self,
525        _b: &str,
526        _k: &str,
527        _v: Bytes,
528        _expected: Option<Revision>,
529    ) -> Result<Revision, BusError> {
530        Ok(1)
531    }
532
533    async fn delete(&self, _b: &str, _k: &str) -> Result<(), BusError> {
534        Ok(())
535    }
536
537    async fn lease(&self, _b: &str, _k: &str, _ttl: Duration) -> Result<Lease, BusError> {
538        Ok(Lease::new(Box::new(NoopLease)))
539    }
540}
541
542// ---- FakeKvStore — real in-memory KV for tests that need actual storage ----
543
544#[derive(Clone, Debug)]
545struct FakeKvEntry {
546    value: Bytes,
547    revision: Revision,
548}
549
550/// In-memory `KvStore` that actually stores data. Use in tests that need
551/// functional KV behaviour (e.g. `KvResumeBuffer`). `NoopKv` discards all
552/// writes and is only suitable for tests that ignore the KV layer.
553#[derive(Default)]
554pub struct FakeKvStore {
555    inner: Mutex<std::collections::HashMap<(String, String), FakeKvEntry>>,
556}
557
558impl FakeKvStore {
559    /// Build an empty store.
560    pub fn new() -> Self {
561        Self::default()
562    }
563}
564
565struct FakeKvLease;
566
567#[async_trait]
568impl LeaseImpl for FakeKvLease {
569    async fn heartbeat(&self) -> Result<(), BusError> {
570        Ok(())
571    }
572}
573
574#[async_trait]
575impl KvStore for FakeKvStore {
576    async fn get(&self, bucket: &str, key: &str) -> Result<Option<KvEntry>, BusError> {
577        let g = self.inner.lock().await;
578        Ok(g.get(&(bucket.into(), key.into())).map(|e| KvEntry {
579            value: e.value.clone(),
580            revision: e.revision,
581        }))
582    }
583
584    async fn put(&self, bucket: &str, key: &str, value: Bytes) -> Result<Revision, BusError> {
585        let mut g = self.inner.lock().await;
586        let map_key = (bucket.into(), key.into());
587        let next_rev = g.get(&map_key).map(|e| e.revision + 1).unwrap_or(1);
588        g.insert(
589            map_key,
590            FakeKvEntry {
591                value,
592                revision: next_rev,
593            },
594        );
595        Ok(next_rev)
596    }
597
598    async fn cas(
599        &self,
600        bucket: &str,
601        key: &str,
602        value: Bytes,
603        expected: Option<Revision>,
604    ) -> Result<Revision, BusError> {
605        let mut g = self.inner.lock().await;
606        let map_key: (String, String) = (bucket.into(), key.into());
607        let existing = g.get(&map_key);
608        match (expected, existing) {
609            (None, None) => {
610                g.insert(map_key, FakeKvEntry { value, revision: 1 });
611                Ok(1)
612            }
613            (None, Some(e)) => Err(BusError::CasConflict {
614                expected: 0,
615                actual: e.revision,
616            }),
617            (Some(want), Some(e)) if e.revision == want => {
618                let next = e.revision + 1;
619                g.insert(
620                    map_key,
621                    FakeKvEntry {
622                        value,
623                        revision: next,
624                    },
625                );
626                Ok(next)
627            }
628            (Some(want), Some(e)) => Err(BusError::CasConflict {
629                expected: want,
630                actual: e.revision,
631            }),
632            (Some(want), None) => Err(BusError::CasConflict {
633                expected: want,
634                actual: 0,
635            }),
636        }
637    }
638
639    async fn delete(&self, bucket: &str, key: &str) -> Result<(), BusError> {
640        self.inner.lock().await.remove(&(bucket.into(), key.into()));
641        Ok(())
642    }
643
644    async fn lease(&self, _b: &str, _k: &str, _ttl: Duration) -> Result<Lease, BusError> {
645        Ok(Lease::new(Box::new(FakeKvLease)))
646    }
647
648    async fn keys(&self, bucket: &str) -> Result<Vec<String>, BusError> {
649        let g = self.inner.lock().await;
650        Ok(g.keys()
651            .filter(|(b, _)| b == bucket)
652            .map(|(_, k)| k.clone())
653            .collect())
654    }
655}
656
657/// Return a `KvStore` backed by real in-memory storage. Use instead of
658/// `noop_bus().2` when the test needs functional KV reads/writes.
659pub fn fake_kv() -> Arc<dyn KvStore> {
660    Arc::new(FakeKvStore::new())
661}
662
663pub struct NoopJobQueue;
664
665#[allow(dead_code)]
666struct NoopClaim;
667
668#[async_trait]
669impl ClaimHandleImpl for NoopClaim {
670    async fn ack(self: Box<Self>) -> Result<(), BusError> {
671        Ok(())
672    }
673    async fn nak(self: Box<Self>, _delay: Duration) -> Result<(), BusError> {
674        Ok(())
675    }
676    async fn dead_letter(self: Box<Self>, _reason: &str) -> Result<(), BusError> {
677        Ok(())
678    }
679}
680
681#[async_trait]
682impl JobQueue for NoopJobQueue {
683    async fn enqueue(&self, _queue: &str, _job: Job) -> Result<JobId, BusError> {
684        Ok(JobId("noop-0".into()))
685    }
686
687    async fn claim(
688        &self,
689        _queue: &str,
690        _worker_id: &str,
691        _ttl: Duration,
692    ) -> Result<Option<ClaimedJob>, BusError> {
693        Ok(None)
694    }
695}
696
697/// Return type for [`noop_bus`].
698pub type NoopBusHandles = (
699    Arc<dyn Pubsub>,
700    Arc<dyn RequestReply>,
701    Arc<dyn KvStore>,
702    Arc<dyn JobQueue>,
703);
704
705/// Convenience: returns ready-to-use no-op bus handles (`Pubsub`,
706/// `RequestReply`, `KvStore`, `JobQueue`).
707pub fn noop_bus() -> NoopBusHandles {
708    (
709        Arc::new(NoopPubsub),
710        Arc::new(NoopRequestReply),
711        Arc::new(NoopKv),
712        Arc::new(NoopJobQueue),
713    )
714}
715
716/// One-call [`AgentContext`] for unit tests: every port is an in-memory
717/// fake (`FakeLlmClient`, `FakeToolInvoker`, in-memory memory triple,
718/// no-op bus handles). Mints a fresh `RunId` and an uncancelled
719/// `CancellationToken`. `progress` is `None`.
720///
721/// Replaces the 12-line `AgentContext { ... }` struct literal at
722/// nearly every test site:
723///
724/// ```
725/// use klieo_core::test_utils::fake_context;
726/// let ctx = fake_context("my-test-agent");
727/// assert_eq!(ctx.agent_name, "my-test-agent");
728/// ```
729///
730/// Mutate fields after the fact when the test needs a specific fake
731/// (e.g. a programmed `FakeLlmClient` or a real impl crate's bus):
732///
733/// ```
734/// use klieo_core::test_utils::{fake_context, FakeLlmClient, FakeLlmStep};
735/// use std::sync::Arc;
736/// let mut ctx = fake_context("scripted");
737/// ctx.llm = Arc::new(
738///     FakeLlmClient::new("scripted")
739///         .with_steps(vec![FakeLlmStep::Text("ok".into())]),
740/// );
741/// ```
742pub fn fake_context(agent_name: impl Into<String>) -> AgentContext {
743    let (pubsub, request_reply, kv, jobs) = noop_bus();
744    AgentContext {
745        llm: Arc::new(FakeLlmClient::new("fake")),
746        short_term: Arc::new(InMemoryShortTerm::default()),
747        long_term: Arc::new(InMemoryLongTerm::default()),
748        episodic: Arc::new(InMemoryEpisodic::default()),
749        pubsub,
750        kv,
751        request_reply,
752        jobs,
753        tools: Arc::new(FakeToolInvoker::new()),
754        run_id: RunId::new(),
755        cancel: CancellationToken::new(),
756        agent_name: agent_name.into(),
757        progress: None,
758        audit_redactor: None,
759        tenant_label: None,
760        parent_anchor: None,
761    }
762}
763
764// ---- TestContext ----
765
766/// One-call test scaffold composing every in-process fake so unit tests
767/// can drive an agent without hand-wiring `Arc<dyn …>` plumbing.
768///
769/// `TestContext::default()` returns a ready-to-use scaffold with empty
770/// fakes; builder methods program the LLM script, register tools, seed
771/// short-term history, and tune [`RunOptions`].
772///
773/// Pair with the `#[klieo::test]` attribute macro for the shortest
774/// possible test shape:
775///
776/// ```ignore
777/// #[klieo::test]
778/// async fn echoes(ctx: TestContext) {
779///     let ctx = ctx.with_canned_llm_responses(vec![
780///         klieo_core::test_utils::FakeLlmStep::Text("hello".into()),
781///     ]);
782///     let out = ctx.run_steps_directly().await.unwrap();
783///     assert_eq!(out, "hello");
784/// }
785/// ```
786pub struct TestContext {
787    llm: Arc<FakeLlmClient>,
788    tools: Arc<dyn ToolInvoker>,
789    short_term: Arc<dyn ShortTermMemory>,
790    long_term: Arc<dyn LongTermMemory>,
791    episodic: Arc<dyn EpisodicMemory>,
792    pubsub: Arc<dyn Pubsub>,
793    request_reply: Arc<dyn RequestReply>,
794    kv: Arc<dyn KvStore>,
795    jobs: Arc<dyn JobQueue>,
796    cancel: CancellationToken,
797    run_id: RunId,
798    agent_name: String,
799    system_prompt: String,
800    run_options: RunOptions,
801    seeded_history: Vec<Message>,
802    // Pending tool registrations applied lazily so callers can chain
803    // `with_tool(...)` repeatedly without forcing us to clone the
804    // already-shared `Arc<dyn ToolInvoker>`. Drained on first runner
805    // call (or via `materialize_tools`) into a fresh `FakeToolInvoker`.
806    pending_tools: Vec<(String, String, ToolHandler)>,
807}
808
809impl Default for TestContext {
810    fn default() -> Self {
811        let (pubsub, request_reply, kv, jobs) = noop_bus();
812        Self {
813            llm: Arc::new(FakeLlmClient::new("test")),
814            tools: Arc::new(FakeToolInvoker::new()),
815            short_term: Arc::new(InMemoryShortTerm::default()),
816            long_term: Arc::new(InMemoryLongTerm::default()),
817            episodic: Arc::new(InMemoryEpisodic::default()),
818            pubsub,
819            request_reply,
820            kv,
821            jobs,
822            cancel: CancellationToken::new(),
823            run_id: RunId::new(),
824            agent_name: "test-agent".into(),
825            system_prompt: String::new(),
826            run_options: RunOptions::default(),
827            seeded_history: Vec::new(),
828            pending_tools: Vec::new(),
829        }
830    }
831}
832
833impl TestContext {
834    /// Program the script the fake LLM serves to `complete()` calls.
835    /// Replaces any previously installed steps.
836    pub fn with_canned_llm_responses(mut self, steps: Vec<FakeLlmStep>) -> Self {
837        self.llm = Arc::new(FakeLlmClient::new(self.llm.name().to_string()).with_steps(steps));
838        self
839    }
840
841    /// Program the script the fake LLM serves to `stream()` calls.
842    /// Replaces any previously installed stream steps.
843    pub fn with_canned_stream_responses(mut self, steps: Vec<FakeStreamStep>) -> Self {
844        self.llm =
845            Arc::new(FakeLlmClient::new(self.llm.name().to_string()).with_stream_steps(steps));
846        self
847    }
848
849    /// Register a tool handler. Multiple calls accumulate into one
850    /// catalogue assembled lazily on the first runner invocation.
851    pub fn with_tool<F>(mut self, name: &str, description: &str, f: F) -> Self
852    where
853        F: Fn(serde_json::Value) -> Result<serde_json::Value, ToolError> + Send + Sync + 'static,
854    {
855        self.pending_tools
856            .push((name.into(), description.into(), Arc::new(f)));
857        self
858    }
859
860    /// Seed short-term memory for the default thread (`ThreadId("test")`)
861    /// before the agent runs. Messages are appended in order on the first
862    /// runner call.
863    pub fn with_short_term_history(mut self, messages: Vec<Message>) -> Self {
864        self.seeded_history = messages;
865        self
866    }
867
868    /// Override the [`RunOptions`] used by [`Self::run_steps_directly`].
869    pub fn with_run_options(mut self, opts: RunOptions) -> Self {
870        self.run_options = opts;
871        self
872    }
873
874    /// Set the system prompt used by [`Self::run_steps_directly`].
875    pub fn with_system_prompt(mut self, prompt: &str) -> Self {
876        self.system_prompt = prompt.into();
877        self
878    }
879
880    /// Set the agent name recorded in `Episode::Started`.
881    pub fn with_agent_name(mut self, name: &str) -> Self {
882        self.agent_name = name.into();
883        self
884    }
885
886    /// Cancel future runs. Inspecting `ctx.cancel` directly is also fine.
887    pub fn cancel_token(&self) -> &CancellationToken {
888        &self.cancel
889    }
890
891    /// The run id used by every runner method on this scaffold.
892    pub fn run_id(&self) -> RunId {
893        self.run_id
894    }
895
896    /// Borrow the fake LLM client to inspect call counts / programmed
897    /// scripts mid-test.
898    pub fn llm(&self) -> &FakeLlmClient {
899        &self.llm
900    }
901
902    /// All episodes recorded against [`Self::run_id`] so far.
903    pub async fn recorded_episodes(&self) -> Vec<Episode> {
904        self.episodic.replay(self.run_id).await.unwrap_or_default()
905    }
906
907    /// Materialise pending tool registrations + seeded history into the
908    /// live fakes. Idempotent. Called by every runner method.
909    async fn materialize(&mut self) -> Result<(), crate::Error> {
910        if !self.pending_tools.is_empty() {
911            // Rebuild the tool invoker from scratch with the accumulated
912            // pending set. Caller-installed `with_tool` calls always win
913            // over the default empty invoker.
914            let mut inv = FakeToolInvoker::new();
915            for (name, desc, handler) in self.pending_tools.drain(..) {
916                inv = inv.with_tool(&name, &desc, move |args| handler(args));
917            }
918            self.tools = Arc::new(inv);
919        }
920        if !self.seeded_history.is_empty() {
921            let thread = ThreadId::new("test");
922            for msg in self.seeded_history.drain(..) {
923                self.short_term.append(thread.clone(), msg).await?;
924            }
925        }
926        Ok(())
927    }
928
929    /// Build a fresh [`AgentContext`] cloning every fake handle.
930    fn build_agent_ctx(&self) -> AgentContext {
931        AgentContext {
932            llm: self.llm.clone() as Arc<dyn LlmClient>,
933            short_term: self.short_term.clone(),
934            long_term: self.long_term.clone(),
935            episodic: self.episodic.clone(),
936            pubsub: self.pubsub.clone(),
937            kv: self.kv.clone(),
938            request_reply: self.request_reply.clone(),
939            jobs: self.jobs.clone(),
940            tools: self.tools.clone(),
941            run_id: self.run_id,
942            cancel: self.cancel.clone(),
943            agent_name: self.agent_name.clone(),
944            progress: None,
945            audit_redactor: None,
946            tenant_label: None,
947            parent_anchor: None,
948        }
949    }
950
951    /// Drive `agent.run(...)` against the assembled fakes. The supplied
952    /// agent must use `klieo_core::Error` as its `Error` associated
953    /// type — keeps the signature simple at the cost of a tiny bit of
954    /// flexibility (custom error types can wrap `crate::Error` inside
955    /// the agent and unwrap on the way out).
956    pub async fn run<A>(&mut self, agent: &A, input: A::Input) -> Result<A::Output, A::Error>
957    where
958        A: crate::Agent<Error = crate::Error>,
959    {
960        self.materialize().await?;
961        let ctx = self.build_agent_ctx();
962        agent.run(ctx, input).await
963    }
964
965    /// Drive [`run_steps`] directly without an `Agent` wrapper — useful
966    /// for one-liner assertions about the runtime + script. Uses
967    /// `ThreadId("test")`, the configured system prompt, and the
968    /// configured [`RunOptions`].
969    pub async fn run_steps_directly(&mut self) -> Result<String, crate::Error> {
970        self.materialize().await?;
971        let ctx = self.build_agent_ctx();
972        let thread = ThreadId::new("test");
973        run_steps(&ctx, &self.system_prompt, thread, self.run_options.clone()).await
974    }
975}