mod form;
mod render;
mod runtime;
mod state;
pub(in crate::frontend) use self::runtime::run;
use super::setup::SetupMode;
use mobius_gateway::wire::ClientMessage;
use uuid::Uuid;
enum Action {
None,
Exit,
Setup {
bot_id: String,
mode: SetupMode,
},
Send {
request_id: String,
message: Box<ClientMessage>,
label: &'static str,
follow_up: FollowUp,
},
}
#[derive(Clone)]
enum FollowUp {
None,
Routines,
Runs(String),
}
fn request_action(
label: &'static str,
follow_up: FollowUp,
make_message: impl FnOnce(String) -> ClientMessage,
) -> Action {
let request_id = Uuid::new_v4().to_string();
let message = make_message(request_id.clone());
Action::Send {
request_id,
message: Box::new(message),
label,
follow_up,
}
}
fn moved(current: usize, length: usize, delta: isize) -> usize {
if length == 0 {
0
} else {
(current as isize + delta).rem_euclid(length as isize) as usize
}
}
#[cfg(test)]
mod tests {
use super::form::MAX_SWARM_TITLE_BYTES;
use super::form::{
AddMemberForm, BotForm, CreateSwarmForm, Form, FormFlow, RoutineForm, TextForm,
};
use super::runtime::handle_frame;
use super::state::{BotsState, Page, available_bot_ids, update_routine_action};
use super::*;
use mobius::protocol::SessionFileLimits;
use mobius_gateway::wire::{
AgentComposition, BotRecord, ProviderTint, ReadyPayload, Routine, RoutineRun,
RoutineRunStatus, RoutineSchedule, RoutineScheduleKind, ServerMessage,
VersionedAgentConfig,
};
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::collections::BTreeSet;
fn gateway(bots: Vec<BotRecord>) -> ReadyPayload {
ReadyPayload {
machine_name: "test".into(),
bots,
sessions: Vec::new(),
background_approvals: Vec::new(),
swarm_attentions: Vec::new(),
swarms: Vec::new(),
providers: Vec::new(),
provider_instances: Vec::new(),
bot_defaults: None,
models: Vec::new(),
model_providers: Default::default(),
middleware_features: Vec::new(),
extensions: Vec::new(),
contributions: Vec::new(),
max_active_sessions: 1,
session_file_limits: SessionFileLimits {
max_attachment_references: 0,
max_file_bytes: 0,
max_session_files: 0,
max_session_bytes: 0,
max_upload_chunk_bytes: 0,
},
}
}
fn bot(id: &str) -> BotRecord {
BotRecord {
id: id.into(),
handle: id.into(),
name: id.into(),
description: "description".into(),
tint: ProviderTint::Teal,
config: VersionedAgentConfig {
revision: 7,
config: AgentComposition::default(),
},
}
}
fn routine(id: &str) -> Routine {
Routine {
id: id.into(),
bot_id: "bot-a".into(),
workspace: "/srv/project".into(),
instructions: "inspect the project".into(),
schedule: RoutineSchedule {
kind: RoutineScheduleKind::Interval,
at: None,
every_seconds: Some(600),
expression: None,
time_zone: None,
},
ends_at: None,
enabled: true,
finished: false,
next_run_at: Some(1),
}
}
fn message(action: Action) -> ClientMessage {
let Action::Send { message, .. } = action else {
panic!("expected gateway request");
};
*message
}
#[test]
fn swarm_picker_offers_only_bots_that_enabled_collaboration() {
let independent = bot("independent");
let mut peer = bot("peer");
peer.config.config.middleware.set_setting(
"bots",
"collaboration",
Some(mobius::protocol::FrontendSettingValue::String(
"swarm".into(),
)),
);
let gateway = gateway(vec![independent, peer]);
assert_eq!(available_bot_ids(&gateway), ["peer"]);
}
#[test]
fn back_keeps_nested_bot_pages_inside_the_manager() {
let mut state = BotsState::new(&gateway(vec![bot("bot-a")]), None, None);
state.page = Page::Routines("bot-a".into());
state.selected = 3;
assert!(matches!(state.back(), Action::None));
assert!(matches!(state.page, Page::Bot(ref id) if id == "bot-a"));
assert_eq!(state.selected, 0);
assert!(matches!(state.back(), Action::None));
assert!(matches!(state.page, Page::Root));
assert!(matches!(state.back(), Action::Exit));
}
#[test]
fn bot_forms_create_and_update_identity_without_changing_configuration() {
let bot = bot("bot-a");
let gateway = gateway(vec![bot.clone()]);
let mut create = BotForm::create();
create.name.value = "Reviewer".into();
create.description.value = "Review focused changes".into();
assert!(matches!(
message(match create.submit(&gateway) {
FormFlow::Send(action) => action,
_ => panic!("expected create request"),
}),
ClientMessage::CreateBot { name, .. } if name == "Reviewer"
));
let mut update = BotForm::update(&bot);
update.name.value = "Renamed".into();
assert!(matches!(
message(match update.submit(&gateway) {
FormFlow::Send(action) => action,
_ => panic!("expected update request"),
}),
ClientMessage::UpdateBot {
expected_revision: 7,
name,
tint: ProviderTint::Teal,
config,
..
} if name == "Renamed" && config == bot.config.config
));
}
#[test]
fn swarm_forms_create_rename_and_add_members() {
let mut create = CreateSwarmForm {
title: TextForm::new("Pair", MAX_SWARM_TITLE_BYTES),
bot_ids: vec!["bot-a".into(), "bot-b".into()],
members: BTreeSet::from(["bot-a".into(), "bot-b".into()]),
leader_bot_id: Some("bot-a".into()),
row: 3,
error: None,
};
assert!(matches!(
message(match create.submit() {
FormFlow::Send(action) => action,
_ => panic!("expected create request"),
}),
ClientMessage::CreateSwarm { leader_bot_id, member_bot_ids, .. }
if leader_bot_id == "bot-a" && member_bot_ids.len() == 2
));
let mut rename = Form::RenameSwarm {
swarm_id: "swarm-a".into(),
title: TextForm::new("Renamed", MAX_SWARM_TITLE_BYTES),
};
assert!(matches!(
rename.handle_key(
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
&gateway(Vec::new()),
),
FormFlow::Send(Action::Send { message, .. })
if matches!(*message, ClientMessage::RenameSwarm { ref title, .. } if title == "Renamed")
));
let mut add = AddMemberForm {
swarm_id: "swarm-a".into(),
bot_ids: vec!["bot-c".into()],
row: 0,
};
assert!(matches!(
add.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
FormFlow::Send(Action::Send { message, .. })
if matches!(*message, ClientMessage::AddSwarmMember { ref bot_id, .. } if bot_id == "bot-c")
));
}
#[test]
fn routine_forms_create_edit_and_toggle_enabled_state() {
let mut create = RoutineForm::create("bot-a".into());
create.workspace.value = "/srv/project".into();
create.instructions.value = "build it".into();
assert!(matches!(
message(create.action().expect("valid create")),
ClientMessage::CreateRoutine { bot_id, schedule, .. }
if bot_id == "bot-a" && schedule.every_seconds == Some(3600)
));
let routine = routine("routine-a");
let update = RoutineForm::update(&routine);
assert!(matches!(
message(update.action().expect("valid update")),
ClientMessage::UpdateRoutine { id, enabled: true, .. } if id == "routine-a"
));
assert!(matches!(
message(update_routine_action(&routine, false)),
ClientMessage::UpdateRoutine { enabled: false, .. }
));
}
#[test]
fn run_requests_are_correlated_and_unrelated_history_is_deferred() {
let mut gateway = gateway(vec![bot("bot-a")]);
let mut state = BotsState::new(&gateway, None, None);
state.begin("owned".into(), "Load run history", FollowUp::None);
let run = RoutineRun {
id: "run-a".into(),
routine_id: "routine-a".into(),
bot_id: "bot-a".into(),
started_at: 1,
finished_at: Some(2),
status: RoutineRunStatus::Succeeded,
session_id: Some("session-a".into()),
message: None,
};
let mut deferred = Vec::new();
handle_frame(
ServerMessage::RoutineHistory {
request_id: "other".into(),
runs: vec![run.clone()],
},
&mut gateway,
&mut state,
&mut deferred,
)
.expect("defer unrelated response");
assert_eq!(deferred.len(), 1);
handle_frame(
ServerMessage::RoutineHistory {
request_id: "owned".into(),
runs: vec![run.clone()],
},
&mut gateway,
&mut state,
&mut deferred,
)
.expect("accept correlated response");
assert_eq!(state.runs, vec![run.clone()]);
assert!(matches!(
message(request_action("Load run", FollowUp::None, |request_id| {
ClientMessage::GetRoutineRunPreview {
request_id,
id: run.id.clone(),
before_sequence: None,
}
})),
ClientMessage::GetRoutineRunPreview { id, .. } if id == "run-a"
));
assert!(matches!(
message(request_action("Delete run", FollowUp::None, |request_id| {
ClientMessage::DeleteRoutineRun { request_id, id: run.id }
})),
ClientMessage::DeleteRoutineRun { id, .. } if id == "run-a"
));
}
}