1use 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#[derive(Debug, Clone)]
14enum Behaviour {
15 Echo,
16 Stream(Vec<okbot_core::AgentProgress>, Duration, String),
18 Fail {
19 retryable: bool,
20 },
21 Reply(String),
22 Sleep(Duration),
23}
24
25#[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 pub fn echo(name: &str) -> Self {
50 Self::with(name, Behaviour::Echo)
51 }
52
53 pub fn replying(name: &str, text: &str) -> Self {
55 Self::with(name, Behaviour::Reply(text.into()))
56 }
57
58 pub fn failing(name: &str, retryable: bool) -> Self {
60 Self::with(name, Behaviour::Fail { retryable })
61 }
62
63 pub fn sleeping(name: &str, d: Duration) -> Self {
65 Self::with(name, Behaviour::Sleep(d))
66 }
67
68 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 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 pub fn enforcing(mut self, policies: &[ToolPolicy]) -> Self {
87 self.caps.tool_policies = policies.to_vec();
88 self
89 }
90
91 pub fn with_caps(mut self, caps: AgentCaps) -> Self {
93 self.caps = caps;
94 self
95 }
96
97 pub fn calls(&self) -> usize {
99 self.calls.load(Ordering::SeqCst)
100 }
101
102 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
161pub 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#[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 std::thread::sleep(Duration::from_millis(10));
192 path
193}
194
195pub 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 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 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 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 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 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 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
339pub 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#[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#[derive(Debug, Default)]
382pub struct CollectSink(Mutex<Vec<okbot_core::InboundEvent>>);
383
384impl CollectSink {
385 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 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}