use std::path::PathBuf;
use std::sync::Arc;
use crate::actions::ToolError;
use crate::actions::models::ModelsQuery;
use crate::actions::route::{RouteInput, RouteQuery};
use crate::actions::status::StatusQuery;
use crate::actions::{ACTIONS, Requires};
use bitrouter_sdk::acp::client::{AcpClient, RouteMethod};
use bitrouter_tui::machine::Command;
use crate::output::CliReport;
use crate::paths::ConfigSource;
const NOT_ROUTABLE: &str = "this session cannot be rerouted (the controller advertises no route \
control: running direct, or without a trusted local daemon binding)";
const NOT_RESETTABLE: &str = "this session has no route lease to drop (the controller advertises \
no route reset)";
pub fn summary_for(action: &str) -> &'static str {
match action {
"status" => "whether the daemon is up, and what it has spent",
"list_models" => "the models this config can route to, optionally by provider",
"route" => "show where a model would be routed, without sending anything",
"commands" => "list the commands this session offers",
"route_set" => "choose the route for the rest of the session",
"route_reset" => "drop the route lease, so the daemon's default applies",
_ => "",
}
}
pub fn offered_commands(client: &AcpClient) -> Vec<Command> {
let capability = client.route_control();
offered_route_commands(
capability.allows(RouteMethod::List),
capability.allows(RouteMethod::Set),
capability.allows(RouteMethod::Reset),
)
}
pub(crate) fn offered_route_commands(list: bool, set: bool, reset: bool) -> Vec<Command> {
ACTIONS
.iter()
.filter_map(|row| {
let name = row.tui_command?;
let unavailable = match (row.requires, row.id) {
(Requires::Nothing, _) => None,
(Requires::Binding, "route_reset") => (!reset).then_some(NOT_RESETTABLE),
(Requires::Binding, _) => (!(list && set)).then_some(NOT_ROUTABLE),
};
Some(Command {
name,
action: row.id,
summary: summary_for(row.id),
unavailable,
})
})
.collect()
}
pub struct SessionSurface<'a> {
pub commands: Vec<Command>,
pub prompt_commands: Vec<bitrouter_tui::machine::PromptCommand>,
pub ports: &'a SessionPorts,
}
pub struct SessionPorts {
status: Arc<dyn StatusQuery>,
models: Arc<dyn ModelsQuery>,
route: Arc<dyn RouteQuery>,
}
impl SessionPorts {
pub fn open(source: ConfigSource, socket: PathBuf) -> Self {
Self {
status: Arc::new(crate::actions::status::DaemonStatus::new(
socket.clone(),
Some(source.clone()),
)),
models: Arc::new(crate::actions::models::RoutableModels::new(
source.clone(),
Some(socket.clone()),
)),
route: Arc::new(crate::actions::route::RouteAction::new(
source,
Some(socket),
)),
}
}
pub async fn run(
&self,
action: &str,
args: &[String],
) -> Result<Box<dyn CliReport>, ToolError> {
match action {
"status" => {
if !args.is_empty() {
return Err(ToolError::new("usage: /status"));
}
Ok(Box::new(self.status.status().await?))
}
"list_models" => Ok(Box::new(
self.models
.list_models()
.await?
.filtered(args.first().map(String::as_str)),
)),
"route" => {
let Some(model) = args.first() else {
return Err(ToolError::new("usage: /preview <model>"));
};
Ok(Box::new(
self.route
.route(RouteInput {
model: model.clone(),
prompt: None,
})
.await?,
))
}
other => Err(ToolError::new(format!("no session action `{other}`"))),
}
}
}
pub fn prompt_commands(
config: &bitrouter_sdk::config::ChatConfig,
) -> anyhow::Result<Vec<bitrouter_tui::machine::PromptCommand>> {
let mut seen: Vec<&str> = Vec::new();
let mut commands = Vec::with_capacity(config.commands.len());
for command in &config.commands {
let name = command.name.trim();
anyhow::ensure!(
!name.is_empty(),
"a `chat.commands` entry has an empty name"
);
anyhow::ensure!(
!name.contains(char::is_whitespace),
"`chat.commands` name `{name}` contains whitespace; a command is one word"
);
anyhow::ensure!(
!name.starts_with('/'),
"`chat.commands` name `{name}` starts with a slash; write it as \
`{}` — the slash is how it is typed, not part of the name",
name.trim_start_matches('/')
);
if let Some(row) = ACTIONS.iter().find(|row| row.tui_command == Some(name)) {
anyhow::bail!(
"`chat.commands` defines `/{name}`, which is BitRouter's own `{}` command. \
Rename it: a config command may not shadow one that reaches BitRouter's ports",
row.id
);
}
if let Some((alias, target)) = bitrouter_tui::machine::ALIASES
.iter()
.find(|(alias, _)| *alias == name)
{
anyhow::bail!(
"`chat.commands` defines `/{alias}`, which is BitRouter's alias for \
`/{target}`. Rename it"
);
}
anyhow::ensure!(
!seen.contains(&name),
"`chat.commands` defines `/{name}` twice"
);
seen.push(name);
commands.push(bitrouter_tui::machine::PromptCommand {
name: name.to_string(),
description: command.description.clone(),
template: command.prompt.clone(),
});
}
Ok(commands)
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::*;
use crate::actions::status::DaemonStatus;
fn config_source(dir: &Path, yaml: &str) -> ConfigSource {
let path = dir.join("bitrouter.yaml");
std::fs::write(&path, yaml).expect("write config");
ConfigSource::File(path)
}
const ONE_MODEL: &str = r#"
providers:
demo:
api_base: https://api.example.test
api_key: sk-test
active: true
models:
- id: demo-model
"#;
#[test]
fn a_config_command_may_not_shadow_a_bitrouter_one() {
use bitrouter_sdk::config::{ChatConfig, PromptCommandConfig};
let entry = |name: &str| PromptCommandConfig {
name: name.to_string(),
description: String::new(),
prompt: "x".to_string(),
};
let commands = |names: &[&str]| ChatConfig {
commands: names.iter().map(|name| entry(name)).collect(),
..Default::default()
};
let ok = prompt_commands(&commands(&["review", "ship"])).expect("no clash");
assert_eq!(ok.len(), 2);
for taken in ["status", "models", "preview", "commands", "route"] {
let error = prompt_commands(&commands(&[taken]))
.expect_err(&format!("`{taken}` is BitRouter's"));
assert!(format!("{error}").contains(taken), "{error}");
}
let error = prompt_commands(&commands(&["help"])).expect_err("`help` is an alias");
assert!(format!("{error}").contains("alias"), "{error}");
let error = prompt_commands(&commands(&["dup", "dup"])).expect_err("duplicate");
assert!(format!("{error}").contains("twice"), "{error}");
let error = prompt_commands(&commands(&["two words"])).expect_err("whitespace");
assert!(format!("{error}").contains("one word"), "{error}");
}
#[tokio::test]
async fn the_session_surface_answers_with_the_cli_leafs_bytes() {
let dir = tempfile::tempdir().expect("tempdir");
let source = config_source(dir.path(), ONE_MODEL);
let socket = dir.path().join("bitrouter.sock");
let leaf = DaemonStatus::new(socket.clone(), Some(source.clone()))
.report()
.await
.expect("cli surface");
let ports = SessionPorts::open(source, socket);
let session = ports.run("status", &[]).await.expect("session surface");
assert_eq!(
serde_json::to_value(&leaf).expect("leaf json"),
serde_json::to_value(session.as_ref()).expect("session json"),
"the session surface must answer with the leaf's bytes"
);
}
#[tokio::test]
async fn the_session_surface_renders_what_the_cli_renders() {
let dir = tempfile::tempdir().expect("tempdir");
let source = config_source(dir.path(), ONE_MODEL);
let socket = dir.path().join("bitrouter.sock");
let leaf = DaemonStatus::new(socket.clone(), Some(source.clone()))
.report()
.await
.expect("cli surface");
let ports = SessionPorts::open(source, socket);
let session = ports.run("status", &[]).await.expect("session surface");
let human = crate::output::Output::new(crate::output::Format::Human);
let expected = human.render_to_vec(&leaf);
assert_eq!(
human.render_to_vec(session.as_ref()),
expected,
"the notice's bytes are the CLI's plain rendering of the same report"
);
assert!(
!expected.is_empty(),
"a report that renders to nothing would make this test vacuous"
);
}
#[tokio::test]
async fn the_models_surface_answers_with_the_cli_leafs_bytes() {
let dir = tempfile::tempdir().expect("tempdir");
let source = config_source(dir.path(), ONE_MODEL);
let socket = dir.path().join("bitrouter.sock");
let leaf =
crate::actions::models::RoutableModels::new(source.clone(), Some(socket.clone()))
.report()
.await
.expect("cli surface")
.filtered(Some("demo"));
let ports = SessionPorts::open(source, socket);
let session = ports
.run("list_models", &["demo".to_string()])
.await
.expect("session surface");
assert_eq!(
serde_json::to_value(&leaf).expect("leaf json"),
serde_json::to_value(session.as_ref()).expect("session json"),
);
assert!(!leaf.models.is_empty(), "the fixture declares one model");
}
#[tokio::test]
async fn the_preview_surface_answers_with_the_cli_leafs_bytes() {
let dir = tempfile::tempdir().expect("tempdir");
let source = config_source(dir.path(), ONE_MODEL);
let socket = dir.path().join("bitrouter.sock");
let leaf = crate::actions::route::RouteAction::new(source.clone(), Some(socket.clone()))
.report(RouteInput {
model: "demo-model".to_string(),
prompt: None,
})
.await
.expect("cli surface");
let ports = SessionPorts::open(source, socket);
let session = ports
.run("route", &["demo-model".to_string()])
.await
.expect("session surface");
assert_eq!(
serde_json::to_value(&leaf).expect("leaf json"),
serde_json::to_value(session.as_ref()).expect("session json"),
);
}
#[tokio::test]
async fn preview_without_a_model_answers_with_its_usage() {
let dir = tempfile::tempdir().expect("tempdir");
let ports = SessionPorts::open(
config_source(dir.path(), ONE_MODEL),
dir.path().join("bitrouter.sock"),
);
match ports.run("route", &[]).await {
Ok(_) => panic!("a model is required"),
Err(error) => assert!(format!("{error}").contains("usage: /preview"), "{error}"),
}
}
#[tokio::test]
async fn an_unknown_action_is_reported_rather_than_panicking() {
let dir = tempfile::tempdir().expect("tempdir");
let source = config_source(dir.path(), ONE_MODEL);
let ports = SessionPorts::open(source, dir.path().join("bitrouter.sock"));
match ports.run("not_a_row", &[]).await {
Ok(_) => panic!("an unknown id must not resolve to a report"),
Err(error) => assert!(format!("{error}").contains("not_a_row"), "{error}"),
}
}
}