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