1use std::time::Duration;
13
14use okbot_core::{
15 AgentRunRecord, ChannelId, Clock, ConversationKey, ConversationStore, MessageRole, RoleGrant,
16 RunId, TokenUsage, ToolCallRecord, ToolDecision, ToolPolicy, Usage, UserId,
17};
18
19use crate::{ManualClock, bot_message, conversation, user_message};
20
21pub async fn conversation_store(store: &dyn ConversationStore, clock: &ManualClock) {
26 let a = conversation("zalo:shop", "room-a");
27 let b = conversation("zalo:shop", "room-b");
28 dedupe(store, clock).await;
29 pairing(store, clock).await;
30 fires_and_direct_chats(store, clock).await;
31 usage_and_tool_calls(store, clock).await;
32 role_grants(store, clock).await;
33
34 for i in 0..5 {
36 store
37 .append(user_message(&a, &format!("q{i}"), clock.now()))
38 .await
39 .expect("append");
40 store
41 .append(bot_message(&a, &format!("r{i}"), clock.now()))
42 .await
43 .expect("append");
44 }
45 store
46 .append(user_message(&b, "other room", clock.now()))
47 .await
48 .expect("append");
49 let recent = store.recent(&a, 3).await.expect("recent");
50 let texts: Vec<&str> = recent.iter().map(|m| m.text.as_str()).collect();
51 assert_eq!(texts, ["r3", "q4", "r4"]);
52 assert_eq!(recent[2].role, MessageRole::Bot);
53 assert_eq!(store.recent(&b, 10).await.expect("recent").len(), 1);
54 assert_eq!(
55 store
56 .recent(&conversation("zalo:shop", "none"), 10)
57 .await
58 .expect("recent"),
59 []
60 );
61
62 assert_eq!(store.session(&a).await.expect("session"), None);
64 store
65 .set_session(&a, "claude", "s-1")
66 .await
67 .expect("set_session");
68 store
69 .set_session(&a, "claude", "s-2")
70 .await
71 .expect("set_session");
72 assert_eq!(
73 store.session(&a).await.expect("session"),
74 Some(("claude".into(), "s-2".into()))
75 );
76 assert_eq!(store.session(&b).await.expect("session"), None);
77
78 store.reset(&a).await.expect("reset");
80 assert_eq!(
81 store.session(&a).await.expect("session"),
82 None,
83 "reset forgets the session"
84 );
85 assert_eq!(store.recent(&a, 10).await.expect("recent"), []);
86 store
87 .append(user_message(&a, "after reset", clock.now()))
88 .await
89 .expect("append");
90 assert_eq!(store.recent(&a, 10).await.expect("recent").len(), 1);
91 assert_eq!(store.recent(&b, 10).await.expect("recent").len(), 1);
92
93 store
95 .record_run(AgentRunRecord {
96 run_id: RunId::new("run-1"),
97 conversation: a.clone(),
98 backend: "fake".into(),
99 model: None,
100 tools: ToolPolicy::Web,
101 usage: None,
102 duration: Duration::from_millis(1500),
103 error: None,
104 started_at: clock.now(),
105 person: None,
106 })
107 .await
108 .expect("record_run");
109
110 clock.advance(Duration::from_secs(3600));
112 store
113 .append(user_message(&b, "new", clock.now()))
114 .await
115 .expect("append");
116 let cutoff = clock
117 .now()
118 .checked_sub(jiff::SignedDuration::from_secs(60))
119 .expect("in range");
120 let removed = store.purge_before(cutoff).await.expect("purge");
121 assert!(removed >= 1, "purge removed nothing");
122 let left: Vec<String> = store
123 .recent(&b, 10)
124 .await
125 .expect("recent")
126 .into_iter()
127 .map(|m| m.text)
128 .collect();
129 assert_eq!(left, ["new"]);
130}
131
132async fn usage_and_tool_calls(store: &dyn ConversationStore, clock: &ManualClock) {
134 let a = conversation("zalo:shop", "usage");
135 let run = |id: &str, person: Option<&str>, tokens: u64| AgentRunRecord {
136 run_id: RunId::new(id),
137 conversation: a.clone(),
138 backend: "fake".into(),
139 model: None,
140 tools: ToolPolicy::Web,
141 usage: Some(TokenUsage {
142 input: tokens,
143 output: 1,
144 ..TokenUsage::default()
145 }),
146 duration: Duration::from_millis(10),
147 error: None,
148 started_at: clock.now(),
149 person: person.map(str::to_string),
150 };
151 let since = clock.now();
152 store
153 .record_run(run("u-0", Some("person:lan"), 99))
154 .await
155 .expect("record_run");
156 clock.advance(Duration::from_secs(10));
157 let later = clock.now();
158 store
159 .record_run(run("u-1", Some("person:lan"), 9))
160 .await
161 .expect("record_run");
162 store
163 .record_run(run("u-2", Some("zalo:shop/other"), 5))
164 .await
165 .expect("record_run");
166 store
167 .record_run(run("u-3", None, 5))
168 .await
169 .expect("record_run");
170 assert_eq!(
171 store.usage_since("person:lan", since).await.expect("usage"),
172 Usage {
173 turns: 2,
174 tokens: 110
175 }
176 );
177 assert_eq!(
178 store.usage_since("person:lan", later).await.expect("usage"),
179 Usage {
180 turns: 1,
181 tokens: 10
182 }
183 );
184 assert_eq!(
185 store.usage_since("nobody", since).await.expect("usage"),
186 Usage::default()
187 );
188
189 let call = |tool: &str, decision| ToolCallRecord {
190 run_id: Some(RunId::new("u-1")),
191 command: None,
192 person: "person:lan".into(),
193 conversation: a.clone(),
194 server: "ops".into(),
195 tool: tool.into(),
196 decision,
197 error: None,
198 duration: Duration::from_millis(7),
199 at: clock.now(),
200 };
201 store
202 .record_tool_call(call("status", ToolDecision::Allowed))
203 .await
204 .expect("record_tool_call");
205 clock.advance(Duration::from_secs(1));
206 let mut denied = call("deploy", ToolDecision::Denied);
207 denied.run_id = None;
208 denied.command = Some("deploy".into());
209 denied.error = Some("not allowed".into());
210 store
211 .record_tool_call(denied.clone())
212 .await
213 .expect("record_tool_call");
214 let calls = store.tool_calls(since, 10).await.expect("tool_calls");
215 assert_eq!(calls.len(), 2);
216 assert_eq!(calls[0], denied, "newest first, read back as written");
217 assert_eq!(calls[1].tool, "status");
218 assert_eq!(
219 store.tool_calls(since, 1).await.expect("tool_calls").len(),
220 1
221 );
222 assert_eq!(
223 store
224 .tool_calls(clock.now() + jiff::SignedDuration::from_secs(1), 10)
225 .await
226 .expect("tool_calls"),
227 []
228 );
229}
230
231async fn role_grants(store: &dyn ConversationStore, clock: &ManualClock) {
233 let grant = |role: &str, account: &str, secs: i64| RoleGrant {
234 role: role.into(),
235 account: account.into(),
236 granted_by: "operator".into(),
237 granted_at: clock.now(),
238 expires_at: clock.now() + jiff::SignedDuration::from_secs(secs),
239 };
240 store
241 .grant_role(grant("ops", "zalo:shop/u1", 60))
242 .await
243 .expect("grant");
244 store
245 .grant_role(grant("ops", "zalo:shop/u2", 600))
246 .await
247 .expect("grant");
248 store
249 .grant_role(grant("ops", "zalo:shop/u1", 120))
250 .await
251 .expect("grant again");
252 let now = clock.now();
253 let grants = store.role_grants(now).await.expect("grants");
254 assert_eq!(grants.len(), 2, "one grant per role and account");
255 assert_eq!(grants[0].account, "zalo:shop/u1");
256 assert_eq!(
257 grants[0].expires_at,
258 now + jiff::SignedDuration::from_secs(120)
259 );
260 assert!(
261 store
262 .revoke_role("ops", "zalo:shop/u2")
263 .await
264 .expect("revoke")
265 );
266 assert!(
267 !store
268 .revoke_role("ops", "zalo:shop/u2")
269 .await
270 .expect("revoke")
271 );
272 clock.advance(Duration::from_secs(121));
273 assert!(
274 store
275 .role_grants(clock.now())
276 .await
277 .expect("grants")
278 .is_empty(),
279 "expired grants are gone"
280 );
281 assert!(
282 !store
283 .revoke_role("ops", "zalo:shop/u1")
284 .await
285 .expect("revoke"),
286 "an expired grant is not revoked"
287 );
288}
289
290async fn dedupe(store: &dyn ConversationStore, clock: &ManualClock) {
292 let channel: okbot_core::ChannelId = "zalo:shop".parse().expect("id");
293 let ttl = Duration::from_secs(60);
294 assert!(
295 store
296 .mark_seen(&channel, "e1", ttl)
297 .await
298 .expect("mark_seen")
299 );
300 assert!(
301 !store
302 .mark_seen(&channel, "e1", ttl)
303 .await
304 .expect("mark_seen")
305 );
306 let other: okbot_core::ChannelId = "zalo:other".parse().expect("id");
307 assert!(
308 store.mark_seen(&other, "e1", ttl).await.expect("mark_seen"),
309 "dedupe is per channel"
310 );
311 clock.advance(Duration::from_secs(61));
312 assert!(
313 store
314 .mark_seen(&channel, "e1", ttl)
315 .await
316 .expect("mark_seen"),
317 "expired mark"
318 );
319}
320
321pub async fn channel(channel: &dyn okbot_core::Channel, to: &okbot_core::ReplyTarget) {
332 use okbot_core::{ChannelError, RenderedMessage};
333
334 let caps = channel.caps().clone();
335 assert!(caps.max_message_len > 0, "max_message_len must be positive");
336 assert_eq!(
337 &to.conversation.channel,
338 channel.id(),
339 "the target belongs to this channel"
340 );
341
342 let sent = channel
343 .send(
344 to,
345 &RenderedMessage {
346 text: "conformance: hello".into(),
347 },
348 )
349 .await
350 .expect("send");
351 assert_eq!(&sent.target, to, "send returns the target it sent to");
352 assert!(
353 !sent.message_id.as_str().is_empty(),
354 "send returns the platform message id"
355 );
356
357 let longest = RenderedMessage {
358 text: "x".repeat(caps.max_message_len),
359 };
360 channel
361 .send(to, &longest)
362 .await
363 .expect("a message of exactly max_message_len is accepted");
364
365 let edited = channel
366 .edit(
367 &sent,
368 &RenderedMessage {
369 text: "conformance: edited".into(),
370 },
371 )
372 .await;
373 match (caps.edit, edited) {
374 (true, Ok(())) | (false, Err(ChannelError::Unsupported(_))) => {}
375 (true, Err(e)) => panic!("caps.edit is set but edit failed: {e}"),
376 (false, other) => panic!("caps.edit is unset, so edit must be Unsupported: {other:?}"),
377 }
378
379 channel
381 .typing(to)
382 .await
383 .expect("typing succeeds (a no-op when unsupported)");
384}
385
386pub async fn channel_stops(
391 channel: std::sync::Arc<dyn okbot_core::Channel>,
392 sink: std::sync::Arc<dyn okbot_core::InboundSink>,
393 shutdown: okbot_core::CancellationToken,
394) {
395 let run = tokio::spawn({
396 let shutdown = shutdown.clone();
397 async move { channel.run(sink, shutdown).await }
398 });
399 tokio::time::sleep(Duration::from_millis(200)).await;
400 shutdown.cancel();
401 tokio::time::timeout(Duration::from_secs(10), run)
402 .await
403 .expect("run returns within 10 s of shutdown")
404 .expect("run does not panic")
405 .expect("run returns Ok on shutdown");
406}
407
408async fn fires_and_direct_chats(store: &dyn ConversationStore, clock: &ManualClock) {
409 use okbot_core::FireState;
410 let at = clock.now();
411 let first = store.claim_fire("tasks/x/t-1", at).await.expect("claim");
412 assert_eq!((first.state, first.attempts), (FireState::Sending, 0));
413 store
414 .set_fire("tasks/x/t-1", at, FireState::Sending, 2)
415 .await
416 .expect("set_fire");
417 let again = store.claim_fire("tasks/x/t-1", at).await.expect("claim");
418 assert_eq!(again.attempts, 2, "a claim keeps the existing record");
419 store
420 .set_fire("tasks/x/t-1", at, FireState::Sent, 3)
421 .await
422 .expect("set_fire");
423 let sent = store.claim_fire("tasks/x/t-1", at).await.expect("claim");
424 assert_eq!(sent.state, FireState::Sent, "a sent firing stays sent");
425 let other = store
426 .claim_fire("tasks/x/t-1", at + jiff::SignedDuration::from_hours(24))
427 .await
428 .expect("claim");
429 assert_eq!(
430 other.state,
431 FireState::Sending,
432 "each firing time is its own record"
433 );
434 let recent = store.recent_fires(10).await.expect("recent_fires");
435 assert_eq!(recent.len(), 2);
436
437 let channel: ChannelId = "discord:main".parse().expect("valid id");
438 let user = UserId::new("u1");
439 assert_eq!(
440 store
441 .direct_chat(&channel, &user)
442 .await
443 .expect("direct_chat"),
444 None
445 );
446 let dm = ConversationKey::room(channel.clone(), "dm-1");
447 store
448 .set_direct_chat(&channel, &user, &dm)
449 .await
450 .expect("set_direct_chat");
451 let newer = ConversationKey::room(channel.clone(), "dm-2");
452 store
453 .set_direct_chat(&channel, &user, &newer)
454 .await
455 .expect("set_direct_chat");
456 assert_eq!(
457 store
458 .direct_chat(&channel, &user)
459 .await
460 .expect("direct_chat"),
461 Some(newer)
462 );
463}
464
465async fn pairing(store: &dyn ConversationStore, clock: &ManualClock) {
466 let zalo: okbot_core::ChannelId = "zalo:shop".parse().expect("valid id");
467 let discord: okbot_core::ChannelId = "discord:shop".parse().expect("valid id");
468 let me = okbot_core::UserId::new("me");
469 assert_eq!(store.owners().await.expect("owners"), vec![]);
470
471 store
472 .create_pairing_code("111111", Duration::from_secs(600))
473 .await
474 .expect("create");
475 assert!(
476 store
477 .redeem_pairing_code("111111", &zalo, &me)
478 .await
479 .expect("redeem"),
480 "a fresh code pairs"
481 );
482 assert!(
483 !store
484 .redeem_pairing_code("111111", &discord, &me)
485 .await
486 .expect("redeem"),
487 "codes are single-use"
488 );
489 assert_eq!(
490 store.owners().await.expect("owners"),
491 vec![(zalo.clone(), me.clone())]
492 );
493
494 store
496 .create_pairing_code("222222", Duration::from_secs(60))
497 .await
498 .expect("create");
499 clock.advance(Duration::from_secs(61));
500 assert!(
501 !store
502 .redeem_pairing_code("222222", &discord, &me)
503 .await
504 .expect("redeem")
505 );
506
507 store
509 .create_pairing_code("333333", Duration::from_secs(600))
510 .await
511 .expect("create");
512 for _ in 0..okbot_core::PAIRING_MAX_FAILURES {
513 assert!(
514 !store
515 .redeem_pairing_code("000000", &discord, &me)
516 .await
517 .expect("redeem")
518 );
519 }
520 assert!(
521 !store
522 .redeem_pairing_code("333333", &discord, &me)
523 .await
524 .expect("redeem"),
525 "cancelled after too many failures"
526 );
527
528 assert!(store.remove_owner(&zalo, &me).await.expect("remove"));
529 assert!(!store.remove_owner(&zalo, &me).await.expect("remove"));
530 assert_eq!(store.owners().await.expect("owners"), vec![]);
531}