lazyBoy/crates/api/src/routing.rs

1054 lines
34 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Who is allowed to answer a group message.
//!
//! A room used to wake every member for every message, so a group of four paid
//! for four model calls (and four desktop claims) to get one useful answer. The
//! audience is now decided once, when the message arrives: an `@name` is law,
//! and without one a single short model call picks the members whose role fits
//! the ask. When nothing fits, the host answers — a message is never left with
//! nobody to read it, because a silently dropped message is worse than one
//! wasted reply.
use std::time::Duration;
use lazyboy_contracts::ModelProvider;
use lazyboy_harness::{
CredentialChain, DynModel, ResolveModelRequest, connect_model, resolve_backend,
};
use rig_core::completion::message::{AssistantContent, Message, UserContent};
use crate::db::Actor;
use crate::state::AppState;
/// The picker is a nicety, not a gate: past this the host takes over, so a slow
/// helper never holds up a chat message.
const ROUTER_TIMEOUT: Duration = Duration::from_millis(1200);
/// How many members one unprefixed message may wake.
const ROUTER_CAP: usize = 3;
/// Recent lines and role text handed to the picker.
const ROUTER_HISTORY: i64 = 6;
const FIELD_CHARS: usize = 100;
const LINE_CHARS: usize = 200;
/// `@所有人` and its spellings: everybody, and no model call at all. The web
/// composer offers the escape hatch in whatever language the screen is in, so
/// every spelling it can insert has to be understood here too.
const ALL_KEYWORDS: [&str; 5] = ["所有人", "全部", "all", "everyone", "everybody"];
const PICKER_SYSTEM: &str = "You decide which members of a group chat should answer the newest message.
Reply with names from the roster only, separated by 、, at most three of them. Choose a member when the message falls inside what that member does. Reply NONE when no roster member fits, and add nothing else.
Never invent a name, never explain your choice, and never answer the message yourself.";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Member {
pub id: String,
pub name: String,
/// What this member says it does (title + description). Picker input only;
/// empty is fine.
pub role: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Mentions {
/// `@所有人` appeared.
pub all: bool,
/// Member ids in the order they were named.
pub ids: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Reason {
All,
Mention,
Routed,
Host,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Audience {
pub targets: Vec<String>,
pub reason: Reason,
}
/// A room reduced to what routing needs: who leads it and who is in it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Room {
pub id: String,
pub host_id: String,
pub members: Vec<Member>,
}
/// Names written as `@名字`, longest name first so a short member cannot steal a
/// longer member's mention.
pub fn parse_mentions(text: &str, members: &[Member]) -> Mentions {
let mut mentions = Mentions::default();
let chars: Vec<char> = text.chars().collect();
for (position, ch) in chars.iter().enumerate() {
// `mail@example.com` is not a mention: `@` has to open a word.
if *ch != '@' || (position > 0 && chars[position - 1].is_alphanumeric()) {
continue;
}
let tail: String = chars[position + 1..].iter().collect();
if ALL_KEYWORDS.iter().any(|word| name_hit(&tail, word)) {
mentions.all = true;
continue;
}
if let Some(id) = find_names(&tail, members, true).into_iter().next()
&& !mentions.ids.contains(&id)
{
mentions.ids.push(id);
}
}
mentions
}
/// Names of roster members that appear in `text`, in order of appearance.
/// `anchored` accepts a name only at the very start of `text`, which is what a
/// mention needs after the `@`.
fn find_names(text: &str, members: &[Member], anchored: bool) -> Vec<String> {
let mut ranked: Vec<&Member> = members
.iter()
.filter(|member| !member.name.trim().is_empty())
.collect();
ranked.sort_by_key(|member| std::cmp::Reverse(member.name.chars().count()));
let chars: Vec<char> = text.chars().collect();
let mut ids: Vec<String> = Vec::new();
let mut position = 0usize;
while position < chars.len() {
let tail: String = chars[position..].iter().collect();
if let Some(member) = ranked.iter().find(|member| name_hit(&tail, &member.name))
&& !ids.contains(&member.id)
{
ids.push(member.id.clone());
}
if anchored {
break;
}
position += 1;
}
ids
}
/// Does `tail` open with `name`? ASCII names need a word boundary after them so
/// `@Ali` cannot pick "Alice"; CJK names run together, so the longest match is
/// already the boundary.
fn name_hit(tail: &str, name: &str) -> bool {
let lowered = tail.to_lowercase();
let needle = name.trim().to_lowercase();
if needle.is_empty() || !lowered.starts_with(&needle) {
return false;
}
if !needle
.chars()
.next_back()
.is_some_and(|last| last.is_ascii_alphanumeric())
{
return true;
}
match lowered.chars().nth(needle.chars().count()) {
None => true,
Some(next) => !(next.is_ascii_alphanumeric() || next == '-' || next == '_'),
}
}
/// The picker's answer reduced to roster ids. It may write "阿明、小美", a JSON
/// array, or prose naming people; anything outside the roster is dropped.
pub fn parse_router_reply(raw: &str, members: &[Member]) -> Vec<String> {
let mut picked = find_names(raw, members, false);
picked.truncate(ROUTER_CAP);
picked
}
/// Who should run for one message: a name is law, `@所有人` is everybody, and
/// only an unaddressed message consults the picker.
pub fn resolve_audience(
members: &[Member],
mentions: &Mentions,
host_id: &str,
busy: &[String],
routed: &[String],
) -> Audience {
let roster: Vec<&String> = members.iter().map(|member| &member.id).collect();
if mentions.all {
return Audience {
targets: roster.into_iter().cloned().collect(),
reason: Reason::All,
};
}
let named: Vec<String> = mentions
.ids
.iter()
.filter(|id| roster.contains(id))
.cloned()
.collect();
if !named.is_empty() {
return Audience {
targets: named,
reason: Reason::Mention,
};
}
let chosen: Vec<String> = routed
.iter()
.filter(|id| roster.contains(id) && !busy.contains(id))
.cloned()
.collect();
if !chosen.is_empty() {
return Audience {
targets: chosen,
reason: Reason::Routed,
};
}
// Never zero: the host picks up what nobody was chosen for.
Audience {
targets: vec![host_id.to_string()],
reason: Reason::Host,
}
}
/// The room a thread belongs to, or `None` for a one-to-one conversation.
pub(crate) async fn room_for_thread(
state: &AppState,
thread_id: &str,
) -> Result<Option<Room>, String> {
// The column is nullable and the row may be missing: both read as "not a
// room", so the outer `Option` (no row) and the inner one (NULL) are
// flattened together. Decoding the column straight into `String` made every
// one-to-one message fail with an "unexpected null".
let room_id: Option<Option<String>> =
sqlx::query_scalar("SELECT room_id FROM threads WHERE id=$1")
.bind(thread_id)
.fetch_optional(state.pool())
.await
.map_err(|error| error.to_string())?;
let Some(room_id) = room_id.flatten() else {
return Ok(None);
};
let rows: Vec<(String, String, String, String)> = sqlx::query_as(
"SELECT b.id, b.name, b.title, b.description
FROM room_members m JOIN bots b ON b.id=m.bot_id
WHERE m.room_id=$1
ORDER BY m.created_at, b.name",
)
.bind(&room_id)
.fetch_all(state.pool())
.await
.map_err(|error| error.to_string())?;
if rows.is_empty() {
return Ok(None);
}
let members: Vec<Member> = rows
.into_iter()
.map(|(id, name, title, description)| Member {
role: format!("{title} {description}").trim().to_string(),
id,
name,
})
.collect();
let host: Option<Option<String>> =
sqlx::query_scalar("SELECT host_bot_id FROM rooms WHERE id=$1")
.bind(&room_id)
.fetch_optional(state.pool())
.await
.map_err(|error| error.to_string())?;
// A deleted host, or a room written before this column existed, falls back
// to the first member — who is the one that used to answer anyway.
let host_id = host
.flatten()
.filter(|host| members.iter().any(|member| member.id == *host))
.unwrap_or_else(|| members[0].id.clone());
Ok(Some(Room {
id: room_id,
host_id,
members,
}))
}
/// Members of `room` that already have a run of their own in flight.
pub(crate) async fn busy_members(state: &AppState, room: &Room) -> Result<Vec<String>, String> {
let roster: Vec<String> = room
.members
.iter()
.map(|member| member.id.clone())
.collect();
let busy: Vec<String> = sqlx::query_scalar(
"SELECT DISTINCT bot_id FROM runs
WHERE bot_id = ANY($1)
AND status IN ('queued','leased','running','waiting_input','waiting_takeover')",
)
.bind(&roster)
.fetch_all(state.pool())
.await
.map_err(|error| error.to_string())?;
Ok(busy)
}
/// Decide the audience for a message that has not been written yet. The model
/// call happens outside the message transaction, so a slow picker never holds a
/// row lock, and every failure path lands on the host.
pub(crate) async fn audience_for(
state: &AppState,
actor: &Actor,
room: &Room,
thread_id: &str,
text: &str,
) -> Audience {
let mentions = parse_mentions(text, &room.members);
let decided = resolve_audience(&room.members, &mentions, &room.host_id, &[], &[]);
if decided.reason != Reason::Host {
log(room, thread_id, &decided);
return decided;
}
let busy = busy_members(state, room).await.unwrap_or_default();
// Two members need no referee: the host answers, and naming the other one
// is free.
let routed = if room.members.len() > 2 {
pick(state, actor, room, thread_id, text).await
} else {
Vec::new()
};
let decided = resolve_audience(&room.members, &mentions, &room.host_id, &busy, &routed);
log(room, thread_id, &decided);
decided
}
fn log(room: &Room, thread_id: &str, audience: &Audience) {
tracing::info!(
room = room.id,
thread = thread_id,
reason = reason_label(audience.reason),
targets = ?audience.targets,
"room routing"
);
}
pub fn reason_label(reason: Reason) -> &'static str {
match reason {
Reason::All => "all",
Reason::Mention => "mention",
Reason::Routed => "routed",
Reason::Host => "host",
}
}
/// One short model call: which names fit this message?
async fn pick(
state: &AppState,
actor: &Actor,
room: &Room,
thread_id: &str,
text: &str,
) -> Vec<String> {
let model = match router_model(state, actor, thread_id).await {
Ok(model) => model,
Err(error) => {
tracing::warn!("room router unavailable: {error}");
return Vec::new();
}
};
let prompt = match prompt_for(state, room, thread_id, text).await {
Ok(prompt) => prompt,
Err(error) => {
tracing::warn!("room router prompt: {error}");
return Vec::new();
}
};
let attempt = tokio::time::timeout(
ROUTER_TIMEOUT,
crate::runs::complete_once(
&model,
Message::User {
content: vec![UserContent::text(prompt)],
},
PICKER_SYSTEM,
&[],
&[],
),
)
.await;
let raw = match attempt {
Ok(Ok(parts)) => parts
.into_iter()
.filter_map(|part| match part {
AssistantContent::Text(text) => Some(text.text),
_ => None,
})
.collect::<Vec<_>>()
.join(" "),
Ok(Err(error)) => {
tracing::warn!("room router call: {error}");
return Vec::new();
}
Err(_) => {
tracing::warn!("room router timed out; the host answers");
return Vec::new();
}
};
parse_router_reply(&raw, &room.members)
}
async fn prompt_for(
state: &AppState,
room: &Room,
thread_id: &str,
text: &str,
) -> Result<String, String> {
let recent: Vec<(String, String)> = sqlx::query_as(
"SELECT COALESCE(b.name, '你') AS speaker, m.body
FROM messages m LEFT JOIN bots b ON b.id=m.speaker_bot_id
WHERE m.thread_id=$1
ORDER BY m.seq DESC LIMIT $2",
)
.bind(thread_id)
.bind(ROUTER_HISTORY)
.fetch_all(state.pool())
.await
.map_err(|error| error.to_string())?;
let mut prompt = String::from("Roster (name — what they do):\n");
for member in &room.members {
prompt.push_str(&format!(
"- {}{}\n",
clip(&member.name, FIELD_CHARS),
if member.role.is_empty() {
"".to_string()
} else {
clip(&member.role, FIELD_CHARS)
}
));
}
if !recent.is_empty() {
prompt.push_str("\nRecent lines (newest last):\n");
for (speaker, body) in recent.iter().rev() {
prompt.push_str(&format!(
"{}: {}\n",
clip(speaker, 40),
clip(body, LINE_CHARS)
));
}
}
prompt.push_str("\nNewest message:\n");
prompt.push_str(text.trim());
prompt.push_str("\n\nNames that should answer:");
Ok(prompt)
}
/// The picker runs on the workspace model unless `LAZYBOY_ROUTER_MODEL` names a
/// cheaper one; leaving it unset is a valid and common state.
async fn router_model(
state: &AppState,
actor: &Actor,
thread_id: &str,
) -> Result<DynModel, String> {
let space = state
.db
.get_space(actor)
.await
.map_err(|error| error.to_string())?
.ok_or_else(|| "workspace not found".to_string())?;
let provider = space
.default_model_provider
.parse::<ModelProvider>()
.map_err(|error| error.to_string())?;
let override_id = std::env::var("LAZYBOY_ROUTER_MODEL")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let (base_url, api_key) =
crate::workspace::credentials_for_provider(&state.db, actor, &space, provider.as_str())
.await
.map_err(|error| error.to_string())?;
let backend = resolve_backend(ResolveModelRequest {
provider,
model_id: override_id.or(Some(space.default_model_id)),
base_url,
credentials: CredentialChain {
bot: None,
space: api_key,
},
})
.map_err(|error| error.to_string())?;
connect_model(&backend, thread_id).map_err(|error| error.to_string())
}
/// The single hand-off a bot may pass on: a member it names that is neither
/// itself nor already working. `@所有人` from a bot is ignored on purpose — a
/// bot must never broadcast the room.
pub(crate) async fn handoff_target(
state: &AppState,
run_id: &str,
from_bot_id: &str,
body: &str,
) -> Result<Option<String>, String> {
let producing: Option<(String, Option<String>, String)> = sqlx::query_as(
"SELECT r.thread_id, t.room_id, r.\"trigger\"
FROM runs r JOIN threads t ON t.id=r.thread_id WHERE r.id=$1",
)
.bind(run_id)
.fetch_optional(state.pool())
.await
.map_err(|error| error.to_string())?;
let Some((thread_id, Some(_room_id), trigger)) = producing else {
return Ok(None);
};
// One hop, hard stop: a run that exists because of a hand-off cannot start
// another one, or two chatty bots would keep the room (and the bill) alive.
if trigger == "handoff" {
return Ok(None);
}
let Some(room) = room_for_thread(state, &thread_id).await? else {
return Ok(None);
};
let mentions = parse_mentions(body, &room.members);
if mentions.all || mentions.ids.is_empty() {
return Ok(None);
}
let busy = busy_members(state, &room).await.unwrap_or_default();
Ok(mentions
.ids
.into_iter()
.find(|id| id != from_bot_id && !busy.contains(id)))
}
/// Trim to whole characters so a clipped role or line never splits in half.
fn clip(text: &str, chars: usize) -> String {
text.chars().take(chars).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn member(id: &str, name: &str) -> Member {
Member {
id: id.to_string(),
name: name.to_string(),
role: String::new(),
}
}
fn roster() -> Vec<Member> {
vec![member("a", "阿明"), member("b", "小美"), member("c", "Dev")]
}
fn ids(values: &[&str]) -> Vec<String> {
values.iter().map(|value| value.to_string()).collect()
}
/// Ids in the order they were found. Anything outside the roster is quoted,
/// so a failure points at the stranger rather than looking like a member.
fn found(picked: &[String], room: &[Member]) -> Vec<String> {
picked
.iter()
.map(|id| {
if room.iter().any(|member| member.id == *id) {
id.clone()
} else {
format!("«{id}»")
}
})
.collect()
}
#[test]
fn only_a_named_member_is_consulted() {
let room = roster();
let mentions = parse_mentions("@小美 幫我看這份報表", &room);
assert!(!mentions.all);
assert_eq!(found(&mentions.ids, &room), ["b"]);
}
#[test]
fn the_longest_name_wins_when_members_overlap() {
let room = vec![member("a", "阿明"), member("b", "阿明哥")];
assert_eq!(
found(&parse_mentions("@阿明哥 來一下", &room).ids, &room),
["b"]
);
assert_eq!(
found(&parse_mentions("@阿明 來一下", &room).ids, &room),
["a"]
);
}
#[test]
fn ascii_names_need_a_word_boundary_after_them() {
let room = roster();
assert_eq!(
found(&parse_mentions("@dev 幫我看", &room).ids, &room),
["c"]
);
assert_eq!(
found(&parse_mentions("@Dev, help", &room).ids, &room),
["c"]
);
assert!(parse_mentions("@Developer 幫我看", &room).ids.is_empty());
}
#[test]
fn punctuation_does_not_eat_a_mention() {
let room = roster();
assert_eq!(
found(&parse_mentions("@小美,@阿明 一起看", &room).ids, &room),
["b", "a"]
);
assert_eq!(found(&parse_mentions("@小美)", &room).ids, &room), ["b"]);
}
#[test]
fn an_email_address_is_not_a_mention() {
let room = roster();
let mentions = parse_mentions("寄到 mail@小美 或 report@dev.example 就好", &room);
assert!(mentions.ids.is_empty() && !mentions.all);
}
#[test]
fn naming_everyone_is_the_escape_hatch() {
let room = roster();
for text in [
"@所有人 結論定了",
"@全部 來看",
"@all look",
"@everyone look",
"@everybody, your turn",
] {
assert!(parse_mentions(text, &room).all, "{text}");
}
// `@allison` is somebody's name, not `all`.
assert!(!parse_mentions("@allison 來了", &room).all);
}
#[test]
fn unknown_names_are_dropped_and_repeats_count_once() {
let room = roster();
let mentions = parse_mentions("@阿明 和 @路人,@阿明 再說一次", &room);
assert_eq!(found(&mentions.ids, &room), ["a"]);
}
#[test]
fn the_picker_may_answer_in_names_json_or_prose() {
let room = roster();
assert_eq!(
found(&parse_router_reply("小美、阿明", &room), &room),
["b", "a"]
);
assert_eq!(
found(&parse_router_reply("[\"小美\", \"Dev\"]", &room), &room),
["b", "c"]
);
assert_eq!(
found(&parse_router_reply("我覺得小美可以回答", &room), &room),
["b"]
);
}
#[test]
fn the_picker_cannot_invent_members_or_a_crowd() {
let room = roster();
assert!(parse_router_reply("NONE", &room).is_empty());
assert!(parse_router_reply("請由 Grace 處理", &room).is_empty());
assert!(parse_router_reply("Development work", &room).is_empty());
let crowd = vec![
member("a", ""),
member("b", ""),
member("c", ""),
member("d", ""),
];
assert_eq!(
parse_router_reply("一、二、三、四", &crowd).len(),
ROUTER_CAP
);
}
#[test]
fn a_named_member_answers_even_while_the_picker_skips_busy_ones() {
let room = roster();
let busy = ids(&["b"]);
let mentioned = resolve_audience(
&room,
&Mentions {
all: false,
ids: ids(&["b"]),
},
"a",
&busy,
&[],
);
assert_eq!(mentioned.targets, ids(&["b"]));
assert_eq!(mentioned.reason, Reason::Mention);
let routed = resolve_audience(&room, &Mentions::default(), "a", &busy, &ids(&["b", "c"]));
assert_eq!(routed.targets, ids(&["c"]));
assert_eq!(routed.reason, Reason::Routed);
}
#[test]
fn everybody_or_nobody_is_settled_without_the_picker() {
let room = roster();
let all = resolve_audience(
&room,
&Mentions {
all: true,
ids: Vec::new(),
},
"a",
&[],
&[],
);
assert_eq!(all.targets, ids(&["a", "b", "c"]));
assert_eq!(all.reason, Reason::All);
// Nothing fitted, the picker named strangers, or it named only busy
// members: the host picks it up rather than the message going unread.
let stranded: Vec<Vec<String>> = vec![vec![], ids(&["ghost"]), ids(&["b"])];
for routed in stranded {
let host = resolve_audience(&room, &Mentions::default(), "a", &ids(&["b"]), &routed);
assert_eq!(host.targets, ids(&["a"]));
assert_eq!(host.reason, Reason::Host);
}
}
#[test]
fn an_id_that_left_the_room_is_not_an_audience() {
let room = roster();
let audience = resolve_audience(
&room,
&Mentions {
all: false,
ids: ids(&["ghost"]),
},
"a",
&[],
&ids(&["c"]),
);
assert_eq!(audience.targets, ids(&["c"]));
assert_eq!(audience.reason, Reason::Routed);
}
#[test]
fn clipping_counts_characters_not_bytes() {
assert_eq!(clip("群組聊天", 3), "群組聊");
}
}
/// The same decisions with the real tables behind them: how many runs one chat
/// message is allowed to start, and where a hand-off stops.
#[cfg(test)]
mod fan_out {
use super::*;
use serde_json::json;
fn app(pool: sqlx::PgPool) -> AppState {
AppState {
db: crate::db::Db { pool },
sandbox: std::sync::Arc::new(lazyboy_sandbox::FakeSandbox::new()),
data_dir: String::new(),
auth: crate::auth::AuthConfig::from_env(),
memory: crate::memory::MemoryService::from_env(),
mcp: crate::mcp::McpHub::new(),
calls: crate::state::CallRegistry::default(),
wakes: crate::state::WakeBus::default(),
}
}
/// `members` join one room led by `host`, and share the thread `t`. A
/// `group` of one is a direct message, which has no audience to choose.
async fn seed(pool: &sqlx::PgPool, members: &[(&str, &str)], host: Option<&str>, group: bool) {
sqlx::query("INSERT INTO users (id,name) VALUES ('u','test')")
.execute(pool)
.await
.unwrap();
sqlx::query("INSERT INTO spaces (id,user_id,name) VALUES ('s','u','test')")
.execute(pool)
.await
.unwrap();
for (id, name) in members {
sqlx::query("INSERT INTO bots (id,space_id,user_id,name) VALUES ($1,'s','u',$2)")
.bind(id)
.bind(name)
.execute(pool)
.await
.unwrap();
}
if group {
sqlx::query(
"INSERT INTO rooms (id,space_id,user_id,name,host_bot_id)
VALUES ('r','s','u','產品群',$1)",
)
.bind(host)
.execute(pool)
.await
.unwrap();
for (id, _) in members {
sqlx::query("INSERT INTO room_members (room_id,bot_id) VALUES ('r',$1)")
.bind(id)
.execute(pool)
.await
.unwrap();
}
}
sqlx::query(
"INSERT INTO threads (id,space_id,user_id,bot_id,room_id) VALUES ('t','s','u',$1,$2)",
)
.bind(members[0].0)
.bind(group.then_some("r"))
.execute(pool)
.await
.unwrap();
}
async fn start_run(pool: &sqlx::PgPool, id: &str, bot: &str, trigger: &str) {
sqlx::query(
"INSERT INTO runs (id,space_id,user_id,bot_id,thread_id,status,trigger,prompt)
VALUES ($1,'s','u',$2,'t','running',$3,'開始')",
)
.bind(id)
.bind(bot)
.bind(trigger)
.execute(pool)
.await
.unwrap();
}
async fn runs_for(pool: &sqlx::PgPool, bot: &str) -> i64 {
sqlx::query_scalar("SELECT count(*) FROM runs WHERE bot_id=$1")
.bind(bot)
.fetch_one(pool)
.await
.unwrap()
}
/// The audience written next to the newest message of `role`. Empty means
/// nothing was recorded, which is what a direct message must do.
async fn audience_of(pool: &sqlx::PgPool, role: &str) -> Vec<String> {
let stored: Option<Option<Vec<String>>> =
sqlx::query_scalar("SELECT reply_bot_ids FROM messages WHERE role=$1 LIMIT 1")
.bind(role)
.fetch_optional(pool)
.await
.unwrap();
stored.flatten().unwrap_or_default()
}
const TRIO: [(&str, &str); 3] = [("a", "阿明"), ("b", "小美"), ("c", "阿強")];
#[sqlx::test(migrations = "../../migrations")]
async fn only_the_member_named_in_the_message_is_woken(pool: sqlx::PgPool) {
seed(&pool, &TRIO, Some("a"), true).await;
let actor = Actor {
user_id: "u".into(),
space_id: "s".into(),
};
crate::runs::send(
&app(pool.clone()),
&actor,
"a",
"t",
"@小美 幫我看這份",
None,
&[],
&[],
)
.await
.unwrap();
assert_eq!(runs_for(&pool, "b").await, 1);
assert_eq!(runs_for(&pool, "a").await, 0);
assert_eq!(runs_for(&pool, "c").await, 0);
assert_eq!(audience_of(&pool, "user").await, ["b"]);
}
#[sqlx::test(migrations = "../../migrations")]
async fn a_two_member_room_answers_without_calling_the_picker(pool: sqlx::PgPool) {
seed(&pool, &[("a", "阿明"), ("b", "小美")], Some("a"), true).await;
let actor = Actor {
user_id: "u".into(),
space_id: "s".into(),
};
crate::runs::send(
&app(pool.clone()),
&actor,
"a",
"t",
"誰幫我看這份",
None,
&[],
&[],
)
.await
.unwrap();
assert_eq!(runs_for(&pool, "a").await, 1);
assert_eq!(runs_for(&pool, "b").await, 0);
assert_eq!(audience_of(&pool, "user").await, ["a"]);
}
#[sqlx::test(migrations = "../../migrations")]
async fn a_mention_is_queued_behind_the_work_already_running(pool: sqlx::PgPool) {
seed(&pool, &TRIO, Some("a"), true).await;
start_run(&pool, "busy", "b", "message").await;
let actor = Actor {
user_id: "u".into(),
space_id: "s".into(),
};
crate::runs::send(
&app(pool.clone()),
&actor,
"a",
"t",
"@小美 再看一次",
None,
&[],
&[],
)
.await
.unwrap();
assert_eq!(runs_for(&pool, "b").await, 2);
}
#[sqlx::test(migrations = "../../migrations")]
async fn a_bot_hands_one_item_over_and_stops_there(pool: sqlx::PgPool) {
seed(&pool, &TRIO, Some("a"), true).await;
start_run(&pool, "r1", "a", "message").await;
let app = app(pool.clone());
crate::runs::append_bot_message_with(
&app,
"t",
"r1",
"a",
"這塊我不熟,@小美 交給妳。",
json!([]),
)
.await
.unwrap();
let passed: Vec<(String, String)> =
sqlx::query_as("SELECT bot_id, \"trigger\" FROM runs WHERE id<>'r1'")
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(passed, vec![("b".to_string(), "handoff".to_string())]);
assert_eq!(audience_of(&pool, "assistant").await, ["b"]);
// The hand-off's own reply starts nothing: one hop is the whole budget,
// or two chatty bots would keep the room and the bill alive.
let handed_run: String = sqlx::query_scalar("SELECT id FROM runs WHERE id<>'r1'")
.fetch_one(&pool)
.await
.unwrap();
crate::runs::append_bot_message_with(
&app,
"t",
&handed_run,
"b",
"@阿強 換妳。",
json!([]),
)
.await
.unwrap();
let total: i64 = sqlx::query_scalar("SELECT count(*) FROM runs")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(total, 2);
}
#[sqlx::test(migrations = "../../migrations")]
async fn a_busy_member_is_not_handed_more_work(pool: sqlx::PgPool) {
seed(&pool, &TRIO, Some("a"), true).await;
start_run(&pool, "busy", "b", "message").await;
start_run(&pool, "r1", "a", "message").await;
crate::runs::append_bot_message_with(
&app(pool.clone()),
"t",
"r1",
"a",
"@小美 或 @阿強,誰有空?",
json!([]),
)
.await
.unwrap();
let passed: Vec<String> =
sqlx::query_scalar("SELECT bot_id FROM runs WHERE \"trigger\"='handoff'")
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(passed, vec!["c".to_string()]);
}
#[sqlx::test(migrations = "../../migrations")]
async fn a_bot_naming_everyone_hands_over_nothing(pool: sqlx::PgPool) {
seed(&pool, &TRIO, Some("a"), true).await;
start_run(&pool, "r1", "a", "message").await;
crate::runs::append_bot_message_with(
&app(pool.clone()),
"t",
"r1",
"a",
"@所有人 一起看這份。",
json!([]),
)
.await
.unwrap();
let total: i64 = sqlx::query_scalar("SELECT count(*) FROM runs")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(total, 1);
}
#[sqlx::test(migrations = "../../migrations")]
async fn a_direct_message_never_broadcasts(pool: sqlx::PgPool) {
seed(&pool, &[("a", "阿明"), ("b", "小美")], None, false).await;
start_run(&pool, "r1", "a", "message").await;
crate::runs::append_bot_message_with(
&app(pool.clone()),
"t",
"r1",
"a",
"@小美 @所有人 一起看。",
json!([]),
)
.await
.unwrap();
let total: i64 = sqlx::query_scalar("SELECT count(*) FROM runs")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(total, 1);
assert!(audience_of(&pool, "assistant").await.is_empty());
}
/// A one-to-one thread has no room to look up; the send must simply reach
/// its bot. This once failed before the message was even written, because a
/// NULL `room_id` was decoded as if it could not be NULL.
#[sqlx::test(migrations = "../../migrations")]
async fn a_direct_message_reaches_its_bot_and_records_no_audience(pool: sqlx::PgPool) {
seed(&pool, &[("a", "阿明")], None, false).await;
let actor = Actor {
user_id: "u".into(),
space_id: "s".into(),
};
crate::runs::send(
&app(pool.clone()),
&actor,
"a",
"t",
"幫我看這份",
None,
&[],
&[],
)
.await
.unwrap();
assert_eq!(runs_for(&pool, "a").await, 1);
assert!(audience_of(&pool, "user").await.is_empty());
}
/// A room whose host was deleted (or that predates the column) still has a
/// host: the first member, as before.
#[sqlx::test(migrations = "../../migrations")]
async fn a_room_without_a_recorded_host_falls_back_to_its_first_member(pool: sqlx::PgPool) {
seed(&pool, &[("a", "阿明"), ("b", "小美")], None, true).await;
let room = room_for_thread(&app(pool.clone()), "t")
.await
.unwrap()
.expect("the thread belongs to a room");
assert_eq!(room.host_id, "a");
}
}