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, 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
21/// Checks a fresh, empty [`ConversationStore`] that reads time from `clock`.
22///
23/// # Panics
24/// On the first behaviour that differs from the port's contract.
25pub 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    // History: oldest first, limited to the newest, separated by conversation.
35    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    // Sessions: stored per conversation, cleared by reset.
63    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    // Reset hides earlier messages of that conversation only.
79    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    // Audit records are accepted.
94    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    // Retention removes old messages.
111    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
132/// Usage per person over a period, and the tool call audit.
133async 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
231/// Temporary roles: listed until they expire, replaced by a new grant, revocable.
232async 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
290/// Dedupe: first sighting is new, repeats are not, expiry makes it new again; per channel.
291async 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
321/// Checks the outbound side of a running [`Channel`](okbot_core::Channel): call it while
322/// [`Channel::run`](okbot_core::Channel::run) is serving (against your fake platform), with a
323/// target in a conversation that channel owns.
324///
325/// It checks the capabilities are usable, that `send` returns a reference to what it sent, that a
326/// message of exactly `max_message_len` is accepted, and that `edit` and `typing` agree with the
327/// capabilities.
328///
329/// # Panics
330/// On the first behaviour that differs from the port's contract.
331pub 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    // Typing is best effort: it never fails a turn, even where the platform has none.
380    channel
381        .typing(to)
382        .await
383        .expect("typing succeeds (a no-op when unsupported)");
384}
385
386/// Checks that [`Channel::run`](okbot_core::Channel::run) returns soon after shutdown.
387///
388/// # Panics
389/// When `run` fails or is still running 10 seconds after the token is cancelled.
390pub 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    // Expired codes do not pair.
495    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    // Too many wrong guesses cancel pending codes.
508    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}