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    prompts: Mutex<Vec<okbot_core::Prompt>>,
204    prompt_updates: Mutex<Vec<(String, String, bool)>>,
205    typing: AtomicUsize,
206    ready: tokio::sync::Notify,
207    opens_direct: bool,
208    buttons: bool,
209}
210
211impl std::fmt::Debug for FakeChannel {
212    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
213        f.debug_struct("FakeChannel")
214            .field("id", &self.id)
215            .finish_non_exhaustive()
216    }
217}
218
219impl FakeChannel {
220    /// A channel `id` (`fake:test`) with `caps`.
221    pub fn new(id: &str, caps: okbot_core::ChannelCaps) -> Self {
222        Self {
223            id: id.parse().expect("valid channel id"),
224            caps,
225            sink: Mutex::new(None),
226            sent: Mutex::new(Vec::new()),
227            edits: Mutex::new(Vec::new()),
228            prompts: Mutex::new(Vec::new()),
229            prompt_updates: Mutex::new(Vec::new()),
230            typing: AtomicUsize::new(0),
231            ready: tokio::sync::Notify::new(),
232            opens_direct: false,
233            buttons: true,
234        }
235    }
236
237    /// Like a platform without buttons: prompts are [`okbot_core::ChannelError::Unsupported`].
238    #[must_use]
239    pub fn without_buttons(mut self) -> Self {
240        self.buttons = false;
241        self
242    }
243
244    /// Lets [`Channel::open_direct`](okbot_core::Channel::open_direct) open a direct chat with
245    /// anyone, as room `dm-<user id>` (Discord, Telegram); without it the call is unsupported.
246    pub fn opening_direct_chats(mut self) -> Self {
247        self.opens_direct = true;
248        self
249    }
250
251    /// Waits until the runtime started this channel, then delivers `event`.
252    pub async fn inject(&self, event: okbot_core::InboundEvent) {
253        loop {
254            let notified = self.ready.notified();
255            let sink = self
256                .sink
257                .lock()
258                .unwrap_or_else(std::sync::PoisonError::into_inner)
259                .clone();
260            if let Some(sink) = sink {
261                sink.deliver(event).await.expect("runtime accepts events");
262                return;
263            }
264            notified.await;
265        }
266    }
267
268    /// Texts sent so far, oldest first.
269    pub fn sent_texts(&self) -> Vec<String> {
270        self.sent
271            .lock()
272            .unwrap_or_else(std::sync::PoisonError::into_inner)
273            .iter()
274            .map(|(_, text)| text.clone())
275            .collect()
276    }
277
278    /// Messages sent so far with their targets.
279    pub fn sent(&self) -> Vec<(okbot_core::ReplyTarget, String)> {
280        self.sent
281            .lock()
282            .unwrap_or_else(std::sync::PoisonError::into_inner)
283            .clone()
284    }
285
286    /// Edits so far: `(message id, new text)`.
287    pub fn edits(&self) -> Vec<(String, String)> {
288        self.edits
289            .lock()
290            .unwrap_or_else(std::sync::PoisonError::into_inner)
291            .clone()
292    }
293
294    /// Prompts shown so far, oldest first.
295    pub fn prompts(&self) -> Vec<okbot_core::Prompt> {
296        self.prompts
297            .lock()
298            .unwrap_or_else(std::sync::PoisonError::into_inner)
299            .clone()
300    }
301
302    /// Prompt updates so far: `(message id, text, buttons kept)`.
303    pub fn prompt_updates(&self) -> Vec<(String, String, bool)> {
304        self.prompt_updates
305            .lock()
306            .unwrap_or_else(std::sync::PoisonError::into_inner)
307            .clone()
308    }
309
310    /// Delivers an answer to a prompt, as a click would.
311    pub async fn answer(&self, answer: okbot_core::PromptAnswer) {
312        let sink = self
313            .sink
314            .lock()
315            .unwrap_or_else(std::sync::PoisonError::into_inner)
316            .clone()
317            .expect("the runtime started this channel");
318        sink.answer(answer).await.expect("runtime accepts answers");
319    }
320
321    /// Typing indicators shown so far.
322    pub fn typing_count(&self) -> usize {
323        self.typing.load(Ordering::SeqCst)
324    }
325}
326
327#[async_trait]
328impl okbot_core::Channel for FakeChannel {
329    fn id(&self) -> &okbot_core::ChannelId {
330        &self.id
331    }
332
333    fn caps(&self) -> &okbot_core::ChannelCaps {
334        &self.caps
335    }
336
337    async fn run(
338        &self,
339        sink: std::sync::Arc<dyn okbot_core::InboundSink>,
340        shutdown: okbot_core::CancellationToken,
341    ) -> Result<(), okbot_core::ChannelError> {
342        *self
343            .sink
344            .lock()
345            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(sink);
346        self.ready.notify_waiters();
347        shutdown.cancelled().await;
348        Ok(())
349    }
350
351    async fn open_direct(
352        &self,
353        user: &okbot_core::UserId,
354    ) -> Result<okbot_core::ConversationKey, okbot_core::ChannelError> {
355        if !self.opens_direct {
356            return Err(okbot_core::ChannelError::Unsupported("open_direct"));
357        }
358        Ok(okbot_core::ConversationKey::room(
359            self.id.clone(),
360            format!("dm-{user}"),
361        ))
362    }
363
364    async fn send(
365        &self,
366        to: &okbot_core::ReplyTarget,
367        message: &okbot_core::RenderedMessage,
368    ) -> Result<okbot_core::SentRef, okbot_core::ChannelError> {
369        let mut sent = self
370            .sent
371            .lock()
372            .unwrap_or_else(std::sync::PoisonError::into_inner);
373        sent.push((to.clone(), message.text.clone()));
374        Ok(okbot_core::SentRef {
375            target: to.clone(),
376            message_id: okbot_core::MessageId::new(format!("sent-{}", sent.len())),
377        })
378    }
379
380    async fn typing(&self, _to: &okbot_core::ReplyTarget) -> Result<(), okbot_core::ChannelError> {
381        self.typing.fetch_add(1, Ordering::SeqCst);
382        Ok(())
383    }
384
385    async fn prompt(
386        &self,
387        to: &okbot_core::ReplyTarget,
388        prompt: &okbot_core::Prompt,
389    ) -> Result<okbot_core::SentRef, okbot_core::ChannelError> {
390        if !self.buttons {
391            return Err(okbot_core::ChannelError::Unsupported("prompt"));
392        }
393        let mut prompts = self
394            .prompts
395            .lock()
396            .unwrap_or_else(std::sync::PoisonError::into_inner);
397        prompts.push(prompt.clone());
398        Ok(okbot_core::SentRef {
399            target: to.clone(),
400            message_id: okbot_core::MessageId::new(format!("prompt-{}", prompts.len())),
401        })
402    }
403
404    async fn close_prompt(
405        &self,
406        sent: &okbot_core::SentRef,
407        message: &okbot_core::RenderedMessage,
408        keep_buttons: bool,
409    ) -> Result<(), okbot_core::ChannelError> {
410        if !self.buttons {
411            return Err(okbot_core::ChannelError::Unsupported("prompt"));
412        }
413        self.prompt_updates
414            .lock()
415            .unwrap_or_else(std::sync::PoisonError::into_inner)
416            .push((
417                sent.message_id.to_string(),
418                message.text.clone(),
419                keep_buttons,
420            ));
421        Ok(())
422    }
423
424    async fn edit(
425        &self,
426        sent: &okbot_core::SentRef,
427        message: &okbot_core::RenderedMessage,
428    ) -> Result<(), okbot_core::ChannelError> {
429        if !self.caps.edit {
430            return Err(okbot_core::ChannelError::Unsupported("edit"));
431        }
432        self.edits
433            .lock()
434            .unwrap_or_else(std::sync::PoisonError::into_inner)
435            .push((sent.message_id.to_string(), message.text.clone()));
436        Ok(())
437    }
438}
439
440/// An inbound text event from user `user` in `room` of `channel`, with a unique event id.
441pub fn inbound(channel: &str, room: &str, user: &str, text: &str) -> okbot_core::InboundEvent {
442    static NEXT: AtomicUsize = AtomicUsize::new(1);
443    let n = NEXT.fetch_add(1, Ordering::SeqCst);
444    okbot_core::InboundEvent {
445        event_id: format!("ev-{n}"),
446        conversation: okbot_core::ConversationKey::room(
447            channel.parse().expect("valid channel id"),
448            room,
449        ),
450        author: okbot_core::UserRef {
451            id: okbot_core::UserId::new(user),
452            display_name: user.to_string(),
453            is_bot: false,
454        },
455        text: text.into(),
456        attachments: Vec::new(),
457        mentions_bot: false,
458        is_direct: true,
459        restricted: false,
460        message_id: Some(okbot_core::MessageId::new(format!("m-{n}"))),
461        sent_at: "2026-01-01T00:00:00Z".parse().expect("timestamp"),
462        locale: None,
463    }
464}
465
466/// A [`Renderer`](okbot_core::Renderer) that sends the Markdown unchanged as one message.
467#[derive(Debug, Clone, Copy, Default)]
468pub struct PlainRenderer;
469
470impl okbot_core::Renderer for PlainRenderer {
471    fn render(
472        &self,
473        markdown: &str,
474        _caps: &okbot_core::ChannelCaps,
475    ) -> Vec<okbot_core::RenderedMessage> {
476        vec![okbot_core::RenderedMessage {
477            text: markdown.to_string(),
478        }]
479    }
480}
481
482/// An [`InboundSink`](okbot_core::InboundSink) that keeps every event, for adapter tests.
483#[derive(Debug, Default)]
484pub struct CollectSink(Mutex<Vec<okbot_core::InboundEvent>>);
485
486impl CollectSink {
487    /// Events delivered so far.
488    pub fn events(&self) -> Vec<okbot_core::InboundEvent> {
489        self.0
490            .lock()
491            .unwrap_or_else(std::sync::PoisonError::into_inner)
492            .clone()
493    }
494
495    /// Waits up to 10 seconds for the first event.
496    ///
497    /// # Panics
498    /// When none arrives.
499    pub async fn first(&self) -> okbot_core::InboundEvent {
500        for _ in 0..500 {
501            if let Some(event) = self.events().into_iter().next() {
502                return event;
503            }
504            tokio::time::sleep(Duration::from_millis(20)).await;
505        }
506        panic!("no inbound event within 10 s");
507    }
508}
509
510#[okbot_core::async_trait]
511impl okbot_core::InboundSink for CollectSink {
512    async fn deliver(&self, event: okbot_core::InboundEvent) -> Result<(), okbot_core::SinkClosed> {
513        self.0
514            .lock()
515            .unwrap_or_else(std::sync::PoisonError::into_inner)
516            .push(event);
517        Ok(())
518    }
519}