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