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}
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 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 pub fn opening_direct_chats(mut self) -> Self {
238 self.opens_direct = true;
239 self
240 }
241
242 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 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 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 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 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 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 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 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
425pub 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#[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#[derive(Debug, Default)]
469pub struct CollectSink(Mutex<Vec<okbot_core::InboundEvent>>);
470
471impl CollectSink {
472 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 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}