Skip to main content

okbot_testkit/
fakes.rs

1//! Fake adapters.
2
3use std::path::{Path, PathBuf};
4use std::sync::Mutex;
5use std::sync::atomic::{AtomicUsize, Ordering};
6use std::time::Duration;
7
8use okbot_core::{
9    AgentBackend, AgentCaps, AgentError, AgentReply, AgentTurn, ToolPolicy, async_trait,
10};
11
12/// What a [`FakeAgent`] does.
13#[derive(Debug, Clone)]
14enum Behaviour {
15    Echo,
16    /// Reports these progress events `gap` apart, then answers `text`.
17    Stream(Vec<okbot_core::AgentProgress>, Duration, String),
18    Fail {
19        retryable: bool,
20    },
21    Reply(String),
22    Sleep(Duration),
23}
24
25/// An [`AgentBackend`] for tests: echoes, replies, fails or sleeps, and records its turns.
26#[derive(Debug)]
27pub struct FakeAgent {
28    name: String,
29    behaviour: Behaviour,
30    caps: AgentCaps,
31    session: Option<String>,
32    calls: AtomicUsize,
33    turns: Mutex<Vec<AgentTurn>>,
34}
35
36impl FakeAgent {
37    fn with(name: &str, behaviour: Behaviour) -> Self {
38        Self {
39            name: name.into(),
40            behaviour,
41            caps: AgentCaps::default(),
42            session: None,
43            calls: AtomicUsize::new(0),
44            turns: Mutex::new(Vec::new()),
45        }
46    }
47
48    /// Answers `echo: <prompt>`.
49    pub fn echo(name: &str) -> Self {
50        Self::with(name, Behaviour::Echo)
51    }
52
53    /// Always answers `text`.
54    pub fn replying(name: &str, text: &str) -> Self {
55        Self::with(name, Behaviour::Reply(text.into()))
56    }
57
58    /// Always fails.
59    pub fn failing(name: &str, retryable: bool) -> Self {
60        Self::with(name, Behaviour::Fail { retryable })
61    }
62
63    /// Sleeps for `d`, then echoes.
64    pub fn sleeping(name: &str, d: Duration) -> Self {
65        Self::with(name, Behaviour::Sleep(d))
66    }
67
68    /// Reports `progress` events `gap` apart, then answers `text`.
69    pub fn streaming(
70        name: &str,
71        progress: Vec<okbot_core::AgentProgress>,
72        gap: Duration,
73        text: &str,
74    ) -> Self {
75        Self::with(name, Behaviour::Stream(progress, gap, text.into()))
76    }
77
78    /// Returns `session` as the native session id and declares resume support.
79    pub fn with_session(mut self, session: &str) -> Self {
80        self.session = Some(session.into());
81        self.caps.resume = true;
82        self
83    }
84
85    /// Declares the tool policies it enforces.
86    pub fn enforcing(mut self, policies: &[ToolPolicy]) -> Self {
87        self.caps.tool_policies = policies.to_vec();
88        self
89    }
90
91    /// Declares native capabilities.
92    pub fn with_caps(mut self, caps: AgentCaps) -> Self {
93        self.caps = caps;
94        self
95    }
96
97    /// Number of turns started.
98    pub fn calls(&self) -> usize {
99        self.calls.load(Ordering::SeqCst)
100    }
101
102    /// The turns received, oldest first.
103    pub fn turns(&self) -> Vec<AgentTurn> {
104        self.turns
105            .lock()
106            .unwrap_or_else(std::sync::PoisonError::into_inner)
107            .clone()
108    }
109}
110
111#[async_trait]
112impl AgentBackend for FakeAgent {
113    fn name(&self) -> &str {
114        &self.name
115    }
116
117    fn caps(&self) -> AgentCaps {
118        self.caps.clone()
119    }
120
121    async fn run(&self, turn: AgentTurn) -> Result<AgentReply, AgentError> {
122        self.calls.fetch_add(1, Ordering::SeqCst);
123        self.turns
124            .lock()
125            .unwrap_or_else(std::sync::PoisonError::into_inner)
126            .push(turn.clone());
127        let text = match &self.behaviour {
128            Behaviour::Echo => format!("echo: {}", turn.prompt),
129            Behaviour::Reply(text) => text.clone(),
130            Behaviour::Fail { retryable } => {
131                return Err(AgentError::Failed {
132                    message: format!("{} failed", self.name),
133                    retryable: *retryable,
134                });
135            }
136            Behaviour::Sleep(d) => {
137                tokio::time::sleep(*d).await;
138                format!("echo: {}", turn.prompt)
139            }
140            Behaviour::Stream(events, gap, text) => {
141                for event in events {
142                    if let Some(progress) = &turn.progress {
143                        progress.report(event.clone());
144                    }
145                    tokio::time::sleep(*gap).await;
146                }
147                text.clone()
148            }
149        };
150        Ok(AgentReply {
151            text,
152            backend: self.name.clone(),
153            model: None,
154            session_id: self.session.clone(),
155            usage: None,
156            duration: Duration::ZERO,
157        })
158    }
159}
160
161/// A minimal turn with `prompt`, the default tool policy and a temp working directory.
162pub fn turn(prompt: &str) -> AgentTurn {
163    AgentTurn {
164        system: String::new(),
165        prompt: prompt.into(),
166        tools: ToolPolicy::default(),
167        mcp: Vec::new(),
168        env: Vec::new(),
169        workdir: std::env::temp_dir().join("okbot-testkit-work"),
170        timeout: Duration::from_secs(10),
171        resume: None,
172        progress: None,
173    }
174}
175
176/// Writes an executable shell script `dir/name` that prints `1.0.0` for `--version` and runs
177/// `body` otherwise (Unix only). Mirrors agent-call's test helper.
178#[cfg(unix)]
179pub fn fake_cli(dir: &Path, name: &str, body: &str) -> PathBuf {
180    use std::os::unix::fs::PermissionsExt;
181    let path = dir.join(name);
182    std::fs::write(
183        &path,
184        format!("#!/bin/sh\nif [ \"$1\" = --version ]; then echo 1.0.0; exit 0; fi\n{body}\n"),
185    )
186    .expect("write fake CLI");
187    let mut permissions = std::fs::metadata(&path).expect("stat").permissions();
188    permissions.set_mode(0o755);
189    std::fs::set_permissions(&path, permissions).expect("chmod");
190    // Some filesystems briefly report ETXTBSY right after creating an executable.
191    std::thread::sleep(Duration::from_millis(10));
192    path
193}
194
195/// A [`Channel`](okbot_core::Channel) for tests: events are injected by the test, sent messages
196/// are recorded.
197pub struct FakeChannel {
198    id: okbot_core::ChannelId,
199    caps: okbot_core::ChannelCaps,
200    sink: Mutex<Option<std::sync::Arc<dyn okbot_core::InboundSink>>>,
201    sent: Mutex<Vec<(okbot_core::ReplyTarget, String)>>,
202    edits: Mutex<Vec<(String, String)>>,
203    typing: AtomicUsize,
204    ready: tokio::sync::Notify,
205}
206
207impl std::fmt::Debug for FakeChannel {
208    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209        f.debug_struct("FakeChannel")
210            .field("id", &self.id)
211            .finish_non_exhaustive()
212    }
213}
214
215impl FakeChannel {
216    /// A channel `id` (`fake:test`) with `caps`.
217    pub fn new(id: &str, caps: okbot_core::ChannelCaps) -> Self {
218        Self {
219            id: id.parse().expect("valid channel id"),
220            caps,
221            sink: Mutex::new(None),
222            sent: Mutex::new(Vec::new()),
223            edits: Mutex::new(Vec::new()),
224            typing: AtomicUsize::new(0),
225            ready: tokio::sync::Notify::new(),
226        }
227    }
228
229    /// Waits until the runtime started this channel, then delivers `event`.
230    pub async fn inject(&self, event: okbot_core::InboundEvent) {
231        loop {
232            let notified = self.ready.notified();
233            let sink = self
234                .sink
235                .lock()
236                .unwrap_or_else(std::sync::PoisonError::into_inner)
237                .clone();
238            if let Some(sink) = sink {
239                sink.deliver(event).await.expect("runtime accepts events");
240                return;
241            }
242            notified.await;
243        }
244    }
245
246    /// Texts sent so far, oldest first.
247    pub fn sent_texts(&self) -> Vec<String> {
248        self.sent
249            .lock()
250            .unwrap_or_else(std::sync::PoisonError::into_inner)
251            .iter()
252            .map(|(_, text)| text.clone())
253            .collect()
254    }
255
256    /// Messages sent so far with their targets.
257    pub fn sent(&self) -> Vec<(okbot_core::ReplyTarget, String)> {
258        self.sent
259            .lock()
260            .unwrap_or_else(std::sync::PoisonError::into_inner)
261            .clone()
262    }
263
264    /// Edits so far: `(message id, new text)`.
265    pub fn edits(&self) -> Vec<(String, String)> {
266        self.edits
267            .lock()
268            .unwrap_or_else(std::sync::PoisonError::into_inner)
269            .clone()
270    }
271
272    /// Typing indicators shown so far.
273    pub fn typing_count(&self) -> usize {
274        self.typing.load(Ordering::SeqCst)
275    }
276}
277
278#[async_trait]
279impl okbot_core::Channel for FakeChannel {
280    fn id(&self) -> &okbot_core::ChannelId {
281        &self.id
282    }
283
284    fn caps(&self) -> &okbot_core::ChannelCaps {
285        &self.caps
286    }
287
288    async fn run(
289        &self,
290        sink: std::sync::Arc<dyn okbot_core::InboundSink>,
291        shutdown: okbot_core::CancellationToken,
292    ) -> Result<(), okbot_core::ChannelError> {
293        *self
294            .sink
295            .lock()
296            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(sink);
297        self.ready.notify_waiters();
298        shutdown.cancelled().await;
299        Ok(())
300    }
301
302    async fn send(
303        &self,
304        to: &okbot_core::ReplyTarget,
305        message: &okbot_core::RenderedMessage,
306    ) -> Result<okbot_core::SentRef, okbot_core::ChannelError> {
307        let mut sent = self
308            .sent
309            .lock()
310            .unwrap_or_else(std::sync::PoisonError::into_inner);
311        sent.push((to.clone(), message.text.clone()));
312        Ok(okbot_core::SentRef {
313            target: to.clone(),
314            message_id: okbot_core::MessageId::new(format!("sent-{}", sent.len())),
315        })
316    }
317
318    async fn typing(&self, _to: &okbot_core::ReplyTarget) -> Result<(), okbot_core::ChannelError> {
319        self.typing.fetch_add(1, Ordering::SeqCst);
320        Ok(())
321    }
322
323    async fn edit(
324        &self,
325        sent: &okbot_core::SentRef,
326        message: &okbot_core::RenderedMessage,
327    ) -> Result<(), okbot_core::ChannelError> {
328        if !self.caps.edit {
329            return Err(okbot_core::ChannelError::Unsupported("edit"));
330        }
331        self.edits
332            .lock()
333            .unwrap_or_else(std::sync::PoisonError::into_inner)
334            .push((sent.message_id.to_string(), message.text.clone()));
335        Ok(())
336    }
337}
338
339/// An inbound text event from user `user` in `room` of `channel`, with a unique event id.
340pub fn inbound(channel: &str, room: &str, user: &str, text: &str) -> okbot_core::InboundEvent {
341    static NEXT: AtomicUsize = AtomicUsize::new(1);
342    let n = NEXT.fetch_add(1, Ordering::SeqCst);
343    okbot_core::InboundEvent {
344        event_id: format!("ev-{n}"),
345        conversation: okbot_core::ConversationKey::room(
346            channel.parse().expect("valid channel id"),
347            room,
348        ),
349        author: okbot_core::UserRef {
350            id: okbot_core::UserId::new(user),
351            display_name: user.to_string(),
352            is_bot: false,
353        },
354        text: text.into(),
355        attachments: Vec::new(),
356        mentions_bot: false,
357        is_direct: true,
358        restricted: false,
359        message_id: Some(okbot_core::MessageId::new(format!("m-{n}"))),
360        sent_at: "2026-01-01T00:00:00Z".parse().expect("timestamp"),
361    }
362}
363
364/// A [`Renderer`](okbot_core::Renderer) that sends the Markdown unchanged as one message.
365#[derive(Debug, Clone, Copy, Default)]
366pub struct PlainRenderer;
367
368impl okbot_core::Renderer for PlainRenderer {
369    fn render(
370        &self,
371        markdown: &str,
372        _caps: &okbot_core::ChannelCaps,
373    ) -> Vec<okbot_core::RenderedMessage> {
374        vec![okbot_core::RenderedMessage {
375            text: markdown.to_string(),
376        }]
377    }
378}
379
380/// An [`InboundSink`](okbot_core::InboundSink) that keeps every event, for adapter tests.
381#[derive(Debug, Default)]
382pub struct CollectSink(Mutex<Vec<okbot_core::InboundEvent>>);
383
384impl CollectSink {
385    /// Events delivered so far.
386    pub fn events(&self) -> Vec<okbot_core::InboundEvent> {
387        self.0
388            .lock()
389            .unwrap_or_else(std::sync::PoisonError::into_inner)
390            .clone()
391    }
392
393    /// Waits up to 10 seconds for the first event.
394    ///
395    /// # Panics
396    /// When none arrives.
397    pub async fn first(&self) -> okbot_core::InboundEvent {
398        for _ in 0..500 {
399            if let Some(event) = self.events().into_iter().next() {
400                return event;
401            }
402            tokio::time::sleep(Duration::from_millis(20)).await;
403        }
404        panic!("no inbound event within 10 s");
405    }
406}
407
408#[okbot_core::async_trait]
409impl okbot_core::InboundSink for CollectSink {
410    async fn deliver(&self, event: okbot_core::InboundEvent) -> Result<(), okbot_core::SinkClosed> {
411        self.0
412            .lock()
413            .unwrap_or_else(std::sync::PoisonError::into_inner)
414            .push(event);
415        Ok(())
416    }
417}