1use std::time::Duration;
13
14use okbot_core::{
15 AgentRunRecord, AutomationRun, ChannelId, Clock, ConversationKey, ConversationStore,
16 MessageRole, PeerTaskRecord, PeerTaskState, RoleGrant, RunId, TokenUsage, ToolCallRecord,
17 ToolDecision, ToolPolicy, Usage, UserId,
18};
19
20use crate::{ManualClock, bot_message, conversation, user_message};
21
22pub async fn conversation_store(store: &dyn ConversationStore, clock: &ManualClock) {
27 let a = conversation("zalo:shop", "room-a");
28 let b = conversation("zalo:shop", "room-b");
29 dedupe(store, clock).await;
30 pairing(store, clock).await;
31 fires_and_direct_chats(store, clock).await;
32 usage_and_tool_calls(store, clock).await;
33 role_grants(store, clock).await;
34 sealed_secrets(store).await;
35 automations(store, clock).await;
36 peer_tasks(store, clock).await;
37
38 for i in 0..5 {
40 store
41 .append(user_message(&a, &format!("q{i}"), clock.now()))
42 .await
43 .expect("append");
44 store
45 .append(bot_message(&a, &format!("r{i}"), clock.now()))
46 .await
47 .expect("append");
48 }
49 store
50 .append(user_message(&b, "other room", clock.now()))
51 .await
52 .expect("append");
53 let recent = store.recent(&a, 3).await.expect("recent");
54 let texts: Vec<&str> = recent.iter().map(|m| m.text.as_str()).collect();
55 assert_eq!(texts, ["r3", "q4", "r4"]);
56 assert_eq!(recent[2].role, MessageRole::Bot);
57 assert_eq!(store.recent(&b, 10).await.expect("recent").len(), 1);
58 assert_eq!(
59 store
60 .recent(&conversation("zalo:shop", "none"), 10)
61 .await
62 .expect("recent"),
63 []
64 );
65
66 assert_eq!(store.session(&a).await.expect("session"), None);
68 store
69 .set_session(&a, "claude", "s-1")
70 .await
71 .expect("set_session");
72 store
73 .set_session(&a, "claude", "s-2")
74 .await
75 .expect("set_session");
76 assert_eq!(
77 store.session(&a).await.expect("session"),
78 Some(("claude".into(), "s-2".into()))
79 );
80 assert_eq!(store.session(&b).await.expect("session"), None);
81
82 store.reset(&a).await.expect("reset");
84 assert_eq!(
85 store.session(&a).await.expect("session"),
86 None,
87 "reset forgets the session"
88 );
89 assert_eq!(store.recent(&a, 10).await.expect("recent"), []);
90 store
91 .append(user_message(&a, "after reset", clock.now()))
92 .await
93 .expect("append");
94 assert_eq!(store.recent(&a, 10).await.expect("recent").len(), 1);
95 assert_eq!(store.recent(&b, 10).await.expect("recent").len(), 1);
96
97 store
99 .record_run(AgentRunRecord {
100 run_id: RunId::new("run-1"),
101 conversation: a.clone(),
102 backend: "fake".into(),
103 model: None,
104 tools: ToolPolicy::Web,
105 usage: None,
106 duration: Duration::from_millis(1500),
107 error: None,
108 started_at: clock.now(),
109 person: None,
110 })
111 .await
112 .expect("record_run");
113
114 clock.advance(Duration::from_secs(3600));
116 store
117 .append(user_message(&b, "new", clock.now()))
118 .await
119 .expect("append");
120 let cutoff = clock
121 .now()
122 .checked_sub(jiff::SignedDuration::from_secs(60))
123 .expect("in range");
124 let removed = store.purge_before(cutoff).await.expect("purge");
125 assert!(removed >= 1, "purge removed nothing");
126 let left: Vec<String> = store
127 .recent(&b, 10)
128 .await
129 .expect("recent")
130 .into_iter()
131 .map(|m| m.text)
132 .collect();
133 assert_eq!(left, ["new"]);
134}
135
136async fn usage_and_tool_calls(store: &dyn ConversationStore, clock: &ManualClock) {
138 let a = conversation("zalo:shop", "usage");
139 let run = |id: &str, person: Option<&str>, tokens: u64| AgentRunRecord {
140 run_id: RunId::new(id),
141 conversation: a.clone(),
142 backend: "fake".into(),
143 model: None,
144 tools: ToolPolicy::Web,
145 usage: Some(TokenUsage {
146 input: tokens,
147 output: 1,
148 ..TokenUsage::default()
149 }),
150 duration: Duration::from_millis(10),
151 error: None,
152 started_at: clock.now(),
153 person: person.map(str::to_string),
154 };
155 let since = clock.now();
156 store
157 .record_run(run("u-0", Some("person:lan"), 99))
158 .await
159 .expect("record_run");
160 clock.advance(Duration::from_secs(10));
161 let later = clock.now();
162 store
163 .record_run(run("u-1", Some("person:lan"), 9))
164 .await
165 .expect("record_run");
166 store
167 .record_run(run("u-2", Some("zalo:shop/other"), 5))
168 .await
169 .expect("record_run");
170 store
171 .record_run(run("u-3", None, 5))
172 .await
173 .expect("record_run");
174 assert_eq!(
175 store.usage_since("person:lan", since).await.expect("usage"),
176 Usage {
177 turns: 2,
178 tokens: 110
179 }
180 );
181 assert_eq!(
182 store.usage_since("person:lan", later).await.expect("usage"),
183 Usage {
184 turns: 1,
185 tokens: 10
186 }
187 );
188 assert_eq!(
189 store.usage_since("nobody", since).await.expect("usage"),
190 Usage::default()
191 );
192
193 let call = |tool: &str, decision| ToolCallRecord {
194 run_id: Some(RunId::new("u-1")),
195 command: None,
196 person: "person:lan".into(),
197 conversation: a.clone(),
198 server: "ops".into(),
199 tool: tool.into(),
200 decision,
201 error: None,
202 duration: Duration::from_millis(7),
203 at: clock.now(),
204 };
205 store
206 .record_tool_call(call("status", ToolDecision::Allowed))
207 .await
208 .expect("record_tool_call");
209 clock.advance(Duration::from_secs(1));
210 let mut denied = call("deploy", ToolDecision::Denied);
211 denied.run_id = None;
212 denied.command = Some("deploy".into());
213 denied.error = Some("not allowed".into());
214 store
215 .record_tool_call(denied.clone())
216 .await
217 .expect("record_tool_call");
218 let calls = store.tool_calls(since, 10).await.expect("tool_calls");
219 assert_eq!(calls.len(), 2);
220 assert_eq!(calls[0], denied, "newest first, read back as written");
221 assert_eq!(calls[1].tool, "status");
222 assert_eq!(
223 store.tool_calls(since, 1).await.expect("tool_calls").len(),
224 1
225 );
226 assert_eq!(
227 store
228 .tool_calls(clock.now() + jiff::SignedDuration::from_secs(1), 10)
229 .await
230 .expect("tool_calls"),
231 []
232 );
233}
234
235async fn sealed_secrets(store: &dyn ConversationStore) {
237 let get = |subject: &'static str| store.sealed("oauth-token", "notion", subject);
238 assert_eq!(get("person:lan").await.expect("sealed"), None);
239 store
240 .set_sealed("oauth-token", "notion", "person:lan", vec![1, 2])
241 .await
242 .expect("set");
243 store
244 .set_sealed("oauth-token", "notion", "person:lan", vec![3])
245 .await
246 .expect("replace");
247 store
248 .set_sealed("oauth-client", "notion", "https://as", vec![9])
249 .await
250 .expect("set");
251 assert_eq!(get("person:lan").await.expect("sealed"), Some(vec![3]));
252 let entries = store.sealed_entries("oauth-token").await.expect("entries");
253 assert_eq!(entries.len(), 1, "one entry per kind, server and subject");
254 assert_eq!(
255 (entries[0].server.as_str(), entries[0].subject.as_str()),
256 ("notion", "person:lan")
257 );
258 assert!(
259 store
260 .delete_sealed("oauth-token", "notion", "person:lan")
261 .await
262 .expect("delete")
263 );
264 assert!(
265 !store
266 .delete_sealed("oauth-token", "notion", "person:lan")
267 .await
268 .expect("delete")
269 );
270 assert_eq!(
271 store
272 .sealed("oauth-client", "notion", "https://as")
273 .await
274 .expect("sealed"),
275 Some(vec![9]),
276 "other kinds stay"
277 );
278}
279
280async fn automations(store: &dyn ConversationStore, clock: &ManualClock) {
281 let items = |ids: &[&str]| ids.iter().map(ToString::to_string).collect::<Vec<_>>();
282 let new = store
283 .claim_items("mail", items(&["a", "b"]))
284 .await
285 .expect("claim");
286 assert_eq!(new, ["a", "b"]);
287 let new = store
288 .claim_items("mail", items(&["b", "c", "a"]))
289 .await
290 .expect("claim");
291 assert_eq!(new, ["c"], "only unseen items, in order");
292 let other = store
293 .claim_items("other", items(&["a"]))
294 .await
295 .expect("claim");
296 assert_eq!(other, ["a"], "each automation has its own items");
297 store
298 .release_items("mail", items(&["c", "zz"]))
299 .await
300 .expect("release");
301 let again = store
302 .claim_items("mail", items(&["a", "c"]))
303 .await
304 .expect("claim");
305 assert_eq!(again, ["c"], "released items are new again");
306 let run = |name: &str, items: u32| AutomationRun {
307 automation: name.into(),
308 at: clock.now(),
309 items,
310 action: "text".into(),
311 error: None,
312 };
313 store
314 .record_automation_run(run("mail", 2))
315 .await
316 .expect("record");
317 clock.advance(Duration::from_secs(60));
318 store
319 .record_automation_run(AutomationRun {
320 error: Some("boom".into()),
321 ..run("mail", 0)
322 })
323 .await
324 .expect("record");
325 store
326 .record_automation_run(run("other", 1))
327 .await
328 .expect("record");
329 let since = clock.now() - jiff::SignedDuration::from_secs(120);
330 let mail = store
331 .automation_runs(Some("mail"), since, 10)
332 .await
333 .expect("runs");
334 assert_eq!(mail.len(), 2);
335 assert_eq!(mail[0].error.as_deref(), Some("boom"), "newest first");
336 let all = store.automation_runs(None, since, 2).await.expect("runs");
337 assert_eq!(all.len(), 2, "limited");
338 let recent = store
339 .automation_runs(None, clock.now(), 10)
340 .await
341 .expect("runs");
342 assert_eq!(recent.len(), 2, "only runs since the given time");
343}
344
345async fn peer_tasks(store: &dyn ConversationStore, clock: &ManualClock) {
346 let task = |id: &str, inbound: bool, message: &str| PeerTaskRecord {
347 id: id.into(),
348 inbound,
349 team: "acme".into(),
350 peer: "lead".into(),
351 context_id: "ctx".into(),
352 message_id: message.into(),
353 state: PeerTaskState::Working,
354 conversation: Some(conversation("peer:acme", "lead/ctx")),
355 trace: "t1".into(),
356 text: None,
357 requester: (!inbound).then(|| okbot_core::UserRef {
358 id: "lan".into(),
359 display_name: "Lan".into(),
360 is_bot: false,
361 }),
362 direct: !inbound,
363 tokens: None,
364 created_at: clock.now(),
365 updated_at: clock.now(),
366 };
367 store
368 .put_peer_task(task("t-1", true, "m-1"))
369 .await
370 .expect("put");
371 store
372 .put_peer_task(task("t-1", false, "m-1"))
373 .await
374 .expect("each direction has its own ids");
375 let found = store
376 .peer_task_by_message(true, "acme", "lead", "m-1")
377 .await
378 .expect("by message")
379 .expect("found");
380 assert_eq!(found.id, "t-1");
381 assert_eq!(
382 found.conversation,
383 Some(conversation("peer:acme", "lead/ctx"))
384 );
385 assert!(
386 store.put_peer_task(task("t-2", true, "m-1")).await.is_err(),
387 "one task per message"
388 );
389 clock.advance(Duration::from_secs(5));
390 store
391 .put_peer_task(PeerTaskRecord {
392 state: PeerTaskState::Completed,
393 text: Some("done".into()),
394 tokens: Some(1200),
395 updated_at: clock.now(),
396 ..task("t-1", false, "m-1")
397 })
398 .await
399 .expect("update");
400 let out = store
401 .peer_task(false, "t-1")
402 .await
403 .expect("get")
404 .expect("found");
405 assert_eq!(
406 (out.state, out.text.as_deref()),
407 (PeerTaskState::Completed, Some("done"))
408 );
409 assert_eq!(out.requester.as_ref().map(|r| r.id.as_str()), Some("lan"));
410 assert!(out.direct);
411 assert_eq!(out.tokens, Some(1200));
412 assert_eq!(store.peer_tasks(10).await.expect("list")[0].id, "t-1");
413 assert!(
414 !store.peer_tasks(10).await.expect("list")[0].inbound,
415 "newest first"
416 );
417 assert_eq!(
418 store
419 .interrupt_peer_tasks(clock.now())
420 .await
421 .expect("interrupt"),
422 1
423 );
424 let failed = store
425 .peer_task(true, "t-1")
426 .await
427 .expect("get")
428 .expect("found");
429 assert_eq!(failed.state, PeerTaskState::Failed);
430 assert_eq!(
431 store
432 .peer_task(false, "t-1")
433 .await
434 .expect("get")
435 .expect("found")
436 .state,
437 PeerTaskState::Completed,
438 "outbound tasks are not interrupted"
439 );
440 assert!(store.peer_task(true, "nope").await.expect("get").is_none());
441}
442
443async fn role_grants(store: &dyn ConversationStore, clock: &ManualClock) {
444 let grant = |role: &str, account: &str, secs: i64| RoleGrant {
445 role: role.into(),
446 account: account.into(),
447 granted_by: "operator".into(),
448 granted_at: clock.now(),
449 expires_at: clock.now() + jiff::SignedDuration::from_secs(secs),
450 };
451 store
452 .grant_role(grant("ops", "zalo:shop/u1", 60))
453 .await
454 .expect("grant");
455 store
456 .grant_role(grant("ops", "zalo:shop/u2", 600))
457 .await
458 .expect("grant");
459 store
460 .grant_role(grant("ops", "zalo:shop/u1", 120))
461 .await
462 .expect("grant again");
463 let now = clock.now();
464 let grants = store.role_grants(now).await.expect("grants");
465 assert_eq!(grants.len(), 2, "one grant per role and account");
466 assert_eq!(grants[0].account, "zalo:shop/u1");
467 assert_eq!(
468 grants[0].expires_at,
469 now + jiff::SignedDuration::from_secs(120)
470 );
471 assert!(
472 store
473 .revoke_role("ops", "zalo:shop/u2")
474 .await
475 .expect("revoke")
476 );
477 assert!(
478 !store
479 .revoke_role("ops", "zalo:shop/u2")
480 .await
481 .expect("revoke")
482 );
483 clock.advance(Duration::from_secs(121));
484 assert!(
485 store
486 .role_grants(clock.now())
487 .await
488 .expect("grants")
489 .is_empty(),
490 "expired grants are gone"
491 );
492 assert!(
493 !store
494 .revoke_role("ops", "zalo:shop/u1")
495 .await
496 .expect("revoke"),
497 "an expired grant is not revoked"
498 );
499}
500
501async fn dedupe(store: &dyn ConversationStore, clock: &ManualClock) {
503 let channel: okbot_core::ChannelId = "zalo:shop".parse().expect("id");
504 let ttl = Duration::from_secs(60);
505 assert!(
506 store
507 .mark_seen(&channel, "e1", ttl)
508 .await
509 .expect("mark_seen")
510 );
511 assert!(
512 !store
513 .mark_seen(&channel, "e1", ttl)
514 .await
515 .expect("mark_seen")
516 );
517 let other: okbot_core::ChannelId = "zalo:other".parse().expect("id");
518 assert!(
519 store.mark_seen(&other, "e1", ttl).await.expect("mark_seen"),
520 "dedupe is per channel"
521 );
522 clock.advance(Duration::from_secs(61));
523 assert!(
524 store
525 .mark_seen(&channel, "e1", ttl)
526 .await
527 .expect("mark_seen"),
528 "expired mark"
529 );
530}
531
532pub async fn channel(channel: &dyn okbot_core::Channel, to: &okbot_core::ReplyTarget) {
543 use okbot_core::{ChannelError, RenderedMessage};
544
545 let caps = channel.caps().clone();
546 assert!(caps.max_message_len > 0, "max_message_len must be positive");
547 assert_eq!(
548 &to.conversation.channel,
549 channel.id(),
550 "the target belongs to this channel"
551 );
552
553 let sent = channel
554 .send(
555 to,
556 &RenderedMessage {
557 text: "conformance: hello".into(),
558 },
559 )
560 .await
561 .expect("send");
562 assert_eq!(&sent.target, to, "send returns the target it sent to");
563 assert!(
564 !sent.message_id.as_str().is_empty(),
565 "send returns the platform message id"
566 );
567
568 let longest = RenderedMessage {
569 text: "x".repeat(caps.max_message_len),
570 };
571 channel
572 .send(to, &longest)
573 .await
574 .expect("a message of exactly max_message_len is accepted");
575
576 let edited = channel
577 .edit(
578 &sent,
579 &RenderedMessage {
580 text: "conformance: edited".into(),
581 },
582 )
583 .await;
584 match (caps.edit, edited) {
585 (true, Ok(())) | (false, Err(ChannelError::Unsupported(_))) => {}
586 (true, Err(e)) => panic!("caps.edit is set but edit failed: {e}"),
587 (false, other) => panic!("caps.edit is unset, so edit must be Unsupported: {other:?}"),
588 }
589
590 channel
592 .typing(to)
593 .await
594 .expect("typing succeeds (a no-op when unsupported)");
595}
596
597pub async fn channel_stops(
602 channel: std::sync::Arc<dyn okbot_core::Channel>,
603 sink: std::sync::Arc<dyn okbot_core::InboundSink>,
604 shutdown: okbot_core::CancellationToken,
605) {
606 let run = tokio::spawn({
607 let shutdown = shutdown.clone();
608 async move { channel.run(sink, shutdown).await }
609 });
610 tokio::time::sleep(Duration::from_millis(200)).await;
611 shutdown.cancel();
612 tokio::time::timeout(Duration::from_secs(10), run)
613 .await
614 .expect("run returns within 10 s of shutdown")
615 .expect("run does not panic")
616 .expect("run returns Ok on shutdown");
617}
618
619async fn fires_and_direct_chats(store: &dyn ConversationStore, clock: &ManualClock) {
620 use okbot_core::FireState;
621 let at = clock.now();
622 let first = store.claim_fire("tasks/x/t-1", at).await.expect("claim");
623 assert_eq!((first.state, first.attempts), (FireState::Sending, 0));
624 store
625 .set_fire("tasks/x/t-1", at, FireState::Sending, 2)
626 .await
627 .expect("set_fire");
628 let again = store.claim_fire("tasks/x/t-1", at).await.expect("claim");
629 assert_eq!(again.attempts, 2, "a claim keeps the existing record");
630 store
631 .set_fire("tasks/x/t-1", at, FireState::Sent, 3)
632 .await
633 .expect("set_fire");
634 let sent = store.claim_fire("tasks/x/t-1", at).await.expect("claim");
635 assert_eq!(sent.state, FireState::Sent, "a sent firing stays sent");
636 let other = store
637 .claim_fire("tasks/x/t-1", at + jiff::SignedDuration::from_hours(24))
638 .await
639 .expect("claim");
640 assert_eq!(
641 other.state,
642 FireState::Sending,
643 "each firing time is its own record"
644 );
645 let recent = store.recent_fires(10).await.expect("recent_fires");
646 assert_eq!(recent.len(), 2);
647
648 let channel: ChannelId = "discord:main".parse().expect("valid id");
649 let user = UserId::new("u1");
650 assert_eq!(
651 store
652 .direct_chat(&channel, &user)
653 .await
654 .expect("direct_chat"),
655 None
656 );
657 let dm = ConversationKey::room(channel.clone(), "dm-1");
658 store
659 .set_direct_chat(&channel, &user, &dm)
660 .await
661 .expect("set_direct_chat");
662 let newer = ConversationKey::room(channel.clone(), "dm-2");
663 store
664 .set_direct_chat(&channel, &user, &newer)
665 .await
666 .expect("set_direct_chat");
667 assert_eq!(
668 store
669 .direct_chat(&channel, &user)
670 .await
671 .expect("direct_chat"),
672 Some(newer)
673 );
674}
675
676async fn pairing(store: &dyn ConversationStore, clock: &ManualClock) {
677 let zalo: okbot_core::ChannelId = "zalo:shop".parse().expect("valid id");
678 let discord: okbot_core::ChannelId = "discord:shop".parse().expect("valid id");
679 let me = okbot_core::UserId::new("me");
680 assert_eq!(store.owners().await.expect("owners"), vec![]);
681
682 store
683 .create_pairing_code("111111", Duration::from_secs(600))
684 .await
685 .expect("create");
686 assert!(
687 store
688 .redeem_pairing_code("111111", &zalo, &me)
689 .await
690 .expect("redeem"),
691 "a fresh code pairs"
692 );
693 assert!(
694 !store
695 .redeem_pairing_code("111111", &discord, &me)
696 .await
697 .expect("redeem"),
698 "codes are single-use"
699 );
700 assert_eq!(
701 store.owners().await.expect("owners"),
702 vec![(zalo.clone(), me.clone())]
703 );
704
705 store
707 .create_pairing_code("222222", Duration::from_secs(60))
708 .await
709 .expect("create");
710 clock.advance(Duration::from_secs(61));
711 assert!(
712 !store
713 .redeem_pairing_code("222222", &discord, &me)
714 .await
715 .expect("redeem")
716 );
717
718 store
720 .create_pairing_code("333333", Duration::from_secs(600))
721 .await
722 .expect("create");
723 for _ in 0..okbot_core::PAIRING_MAX_FAILURES {
724 assert!(
725 !store
726 .redeem_pairing_code("000000", &discord, &me)
727 .await
728 .expect("redeem")
729 );
730 }
731 assert!(
732 !store
733 .redeem_pairing_code("333333", &discord, &me)
734 .await
735 .expect("redeem"),
736 "cancelled after too many failures"
737 );
738
739 assert!(store.remove_owner(&zalo, &me).await.expect("remove"));
740 assert!(!store.remove_owner(&zalo, &me).await.expect("remove"));
741 assert_eq!(store.owners().await.expect("owners"), vec![]);
742}