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 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 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 #[must_use]
239 pub fn without_buttons(mut self) -> Self {
240 self.buttons = false;
241 self
242 }
243
244 pub fn opening_direct_chats(mut self) -> Self {
247 self.opens_direct = true;
248 self
249 }
250
251 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 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 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 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 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 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 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 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
440pub 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#[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#[derive(Debug, Default)]
484pub struct CollectSink(Mutex<Vec<okbot_core::InboundEvent>>);
485
486impl CollectSink {
487 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 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}