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}
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 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 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 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 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 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 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 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 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 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
403pub 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#[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#[derive(Debug, Default)]
447pub struct CollectSink(Mutex<Vec<okbot_core::InboundEvent>>);
448
449impl CollectSink {
450 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 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}