Skip to main content

okbot_testkit/
conformance.rs

1//! Conformance suites: call them from an implementation's tests.
2//!
3//! ```ignore
4//! #[tokio::test]
5//! async fn conformance() {
6//!     let clock = Arc::new(ManualClock::default());
7//!     let store = MyStore::new(clock.clone());
8//!     okbot_testkit::conformance::conversation_store(&store, &clock).await;
9//! }
10//! ```
11
12use 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
22/// Checks a fresh, empty [`ConversationStore`] that reads time from `clock`.
23///
24/// # Panics
25/// On the first behaviour that differs from the port's contract.
26pub 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    // History: oldest first, limited to the newest, separated by conversation.
39    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    // Sessions: stored per conversation, cleared by reset.
67    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    // Reset hides earlier messages of that conversation only.
83    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    // Audit records are accepted.
98    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    // Retention removes old messages.
115    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
136/// Usage per person over a period, and the tool call audit.
137async 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
235/// Temporary roles: listed until they expire, replaced by a new grant, revocable.
236async 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
501/// Dedupe: first sighting is new, repeats are not, expiry makes it new again; per channel.
502async 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
532/// Checks the outbound side of a running [`Channel`](okbot_core::Channel): call it while
533/// [`Channel::run`](okbot_core::Channel::run) is serving (against your fake platform), with a
534/// target in a conversation that channel owns.
535///
536/// It checks the capabilities are usable, that `send` returns a reference to what it sent, that a
537/// message of exactly `max_message_len` is accepted, and that `edit` and `typing` agree with the
538/// capabilities.
539///
540/// # Panics
541/// On the first behaviour that differs from the port's contract.
542pub 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    // Typing is best effort: it never fails a turn, even where the platform has none.
591    channel
592        .typing(to)
593        .await
594        .expect("typing succeeds (a no-op when unsupported)");
595}
596
597/// Checks that [`Channel::run`](okbot_core::Channel::run) returns soon after shutdown.
598///
599/// # Panics
600/// When `run` fails or is still running 10 seconds after the token is cancelled.
601pub 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    // Expired codes do not pair.
706    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    // Too many wrong guesses cancel pending codes.
719    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}