use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::json;
use wabot_core::injection::Container;
use wabot_core::validation::{
FieldType, ModelInfo, ModelValidationError, PropertyInfo, Validate, ValidationError,
};
use wabot_feature_agent::{agent_binding, Agent, AgentError, ANSWER_TOOL_NAME};
use wabot_feature_mindset::{
Mindset, MindsetDescription, MindsetIdentity, MindsetModelRef, MindsetModels, MindsetOperator,
ModelKind, ToolDefinition,
};
use wabot_macros::{singleton, tools, Validate as ValidateDerive};
use super::*;
#[derive(Debug, Serialize, Deserialize, ValidateDerive)]
struct ReadOrderArgs {
#[description("The order id")]
order_id: String,
#[description("Include shipment events")]
#[serde(default)]
with_events: Option<bool>,
}
#[singleton]
#[derive(Default)]
struct OrderTools;
#[tools]
impl OrderTools {
#[tool("Look up an order by id.")]
async fn read_order(&self, args: ReadOrderArgs) -> serde_json::Value {
json!({
"id": args.order_id,
"status": "shipped",
"events": args.with_events.unwrap_or(false),
})
}
}
struct SupportMindset;
#[async_trait]
impl Mindset for SupportMindset {
async fn describe(&self) -> MindsetDescription {
MindsetDescription::new(MindsetIdentity::new("Elisa", "spanish"))
.with_context("The user is a customer.")
.with_skills("Answer questions about orders.")
}
async fn models(&self) -> MindsetModels {
MindsetModels::new().with(ModelKind::Llm, vec![MindsetModelRef::new("claude-opus-5")])
}
}
fn harness() -> ChatBotHarness {
let container = Container::new();
wabot_core::register_singletons!(&container, OrderTools);
ChatBotHarness::builder(Arc::new(SupportMindset))
.tools(OrderTools::register_tools(&container))
.container(container)
.build()
}
#[tokio::test]
async fn a_scripted_reply_comes_back_as_the_turn() {
let harness = harness();
harness.adapter().reply("Ya salió, llega mañana.");
let turn = harness.send("¿dónde está mi pedido?").await.unwrap();
assert_eq!(turn.text(), "Ya salió, llega mañana.");
assert!(turn.tool_calls.is_empty());
assert_eq!(harness.adapter().call_count(), 1);
assert_eq!(harness.adapter().pending(), 0);
}
#[tokio::test]
async fn a_tool_call_executes_the_real_tool() {
let harness = harness();
harness
.adapter()
.call_tool("read_order", json!({ "order_id": "o-7" }));
harness.adapter().reply("Tu pedido o-7 ya salió.");
let turn = harness.send("¿dónde está o-7?").await.unwrap();
assert!(turn.called("read_order"));
let result = turn.tool_calls[0].result.as_deref().expect("a result");
assert!(result.contains("\"status\":\"shipped\""), "{result}");
assert_eq!(
harness.adapter().call_count(),
2,
"the loop asks the model again after running the tool"
);
}
#[tokio::test]
async fn the_model_sees_the_real_prompt_and_tool_schema() {
let harness = harness();
harness.adapter().reply("ok");
harness.send("hola").await.unwrap();
let request = harness.adapter().last_request().expect("a request");
assert!(
request.system_prompt.contains("your name is Elisa"),
"the real MindsetOperator built this: {}",
request.system_prompt
);
assert_eq!(request.tool_names, vec!["read_order"]);
assert_eq!(request.models, vec!["claude-opus-5"]);
}
#[tokio::test]
async fn a_tool_can_be_called_directly() {
let harness = harness();
let result = harness
.call_tool(
"read_order",
json!({ "order_id": "o-1", "with_events": true }),
)
.await
.unwrap();
assert!(result.contains("\"events\":true"), "{result}");
assert_eq!(harness.adapter().call_count(), 0, "no model involved");
}
#[tokio::test]
async fn direct_calls_go_through_the_real_argument_handling() {
let harness = harness();
let nulled = harness
.call_tool(
"read_order",
json!({ "order_id": "o-1", "with_events": null }),
)
.await
.unwrap();
assert!(
nulled.contains("\"events\":false"),
"null means absent: {nulled}"
);
let invalid = harness.call_tool("read_order", json!({})).await.unwrap();
assert!(
invalid.contains("INVALID_JSON_ARGUMENTS"),
"a bad call is reported to the model, not raised: {invalid}"
);
}
#[tokio::test]
async fn history_accumulates_across_turns() {
let harness = harness();
harness.adapter().reply("uno");
harness.adapter().reply("dos");
let first = harness.send("a").await.unwrap();
let second = harness.send("b").await.unwrap();
assert_eq!(first.text(), "uno");
assert_eq!(second.text(), "dos");
assert_eq!(
harness.history().len(),
4,
"two human messages and two bot replies"
);
assert_eq!(second.items.len(), 2, "a turn reports only its own items");
}
#[tokio::test]
async fn an_unscripted_turn_says_what_to_do_about_it() {
let harness = harness();
harness
.adapter()
.call_tool("read_order", json!({ "order_id": "o-7" }));
let error = harness.send("¿dónde está o-7?").await.unwrap_err();
let message = error.to_string();
assert!(message.contains("no scripted turn left"), "{message}");
assert!(
message.contains("calls the adapter again"),
"the message should name the usual cause: {message}"
);
}
#[tokio::test]
async fn a_fallback_reply_covers_turns_a_test_does_not_care_about() {
let container = Container::new();
wabot_core::register_singletons!(&container, OrderTools);
let harness = ChatBotHarness::builder(Arc::new(SupportMindset))
.adapter(Arc::new(
MockChatAdapter::new().with_fallback_reply("(whatever)"),
))
.container(container)
.build();
assert_eq!(harness.send("hola").await.unwrap().text(), "(whatever)");
}
#[tokio::test]
async fn a_turn_can_be_computed_from_what_the_model_was_asked() {
let harness = harness();
harness.adapter().respond_with(|request| {
let has_tool = request.tools.iter().any(|t| t.name == "read_order");
vec![wabot_feature_chat_bot::ChatItem::bot(
wabot_feature_chat_bot::ChatMessage::text(if has_tool {
"puedo consultarlo"
} else {
"no puedo consultarlo"
}),
)]
});
assert_eq!(
harness.send("hola").await.unwrap().text(),
"puedo consultarlo"
);
}
#[derive(Debug, Deserialize, PartialEq)]
struct Triage {
urgency: String,
}
static TRIAGE_INFO: ModelInfo = ModelInfo {
name: "Triage",
properties: &[PropertyInfo {
name: "urgency",
field_type: FieldType::String,
optional: false,
description: Some("low | high"),
constraints: &[],
}],
};
impl Validate for Triage {
fn model_info() -> &'static ModelInfo {
&TRIAGE_INFO
}
fn validate(&self) -> Result<(), ModelValidationError> {
if self.urgency == "low" || self.urgency == "high" {
return Ok(());
}
let mut errors = ModelValidationError::new();
errors.push("urgency", ValidationError::new("should be 'low' or 'high'"));
Err(errors)
}
}
#[singleton]
#[derive(Default)]
struct PrivilegedTools;
#[tools(expose_to_mindsets = false)]
impl PrivilegedTools {
#[tool("Refund an order outright.")]
async fn issue_refund(&self) -> serde_json::Value {
json!({ "refunded": true })
}
}
struct TriageAgent {
container: Container,
}
#[async_trait]
impl Agent for TriageAgent {
async fn instructions(&self) -> String {
"You triage complaints.".into()
}
async fn models(&self) -> MindsetModels {
MindsetModels::new().with(ModelKind::Llm, vec![MindsetModelRef::new("claude-opus-5")])
}
fn tools(&self, _c: &Container) -> Vec<ToolDefinition> {
let mut tools = OrderTools::register_tools(&self.container);
tools.extend(PrivilegedTools::register_tools(&self.container));
tools
}
fn description(&self) -> Option<&str> {
Some("Triage a complaint.")
}
}
fn agent_harness() -> AgentHarness {
let container = Container::new();
wabot_core::register_singletons!(&container, OrderTools, PrivilegedTools);
AgentHarness::builder(Arc::new(TriageAgent {
container: container.clone(),
}))
.container(container)
.build()
}
#[tokio::test]
async fn an_agent_answers_a_typed_question() {
let harness = agent_harness();
harness
.adapter()
.call_tool(ANSWER_TOOL_NAME, json!({ "urgency": "high" }));
let mut session = harness.session().await;
let triage: Triage = session.ask("How urgent?").await.unwrap();
assert_eq!(triage.urgency, "high");
}
#[tokio::test]
async fn a_rejected_answer_is_retried_by_the_model() {
let harness = agent_harness();
harness
.adapter()
.call_tool(ANSWER_TOOL_NAME, json!({ "urgency": "kind of" }));
harness
.adapter()
.call_tool(ANSWER_TOOL_NAME, json!({ "urgency": "low" }));
let mut session = harness.session().await;
let triage: Triage = session.ask("How urgent?").await.unwrap();
assert_eq!(triage.urgency, "low");
assert_eq!(harness.adapter().call_count(), 2);
}
#[tokio::test]
async fn the_harness_exposes_the_real_gating() {
let harness = agent_harness();
harness.adapter().reply("ok");
let mut session = harness.for_agent().for_mindset().session().await;
session.order("go").await.unwrap();
let tools = harness.adapter().last_request().unwrap().tool_names;
assert_eq!(
tools,
vec!["read_order"],
"expose_to_mindsets = false hides the privileged set on the delegation path"
);
let harness = agent_harness();
harness.adapter().reply("ok");
harness.session().await.order("go").await.unwrap();
assert_eq!(
harness.adapter().last_request().unwrap().tool_names,
vec!["read_order", "issue_refund"]
);
}
#[tokio::test]
async fn a_question_instead_of_an_answer_surfaces_as_an_error() {
let harness = agent_harness();
harness.adapter().reply("Which complaint do you mean?");
let mut session = harness.session().await;
let error = session.ask::<Triage>("How urgent?").await.unwrap_err();
assert!(matches!(error, AgentError::Question { .. }), "{error}");
assert!(error.to_string().contains("Which complaint"));
}
#[tokio::test]
async fn a_mindset_can_delegate_to_the_harnessed_agent() {
let harness = agent_harness();
let agent = Arc::new(TriageAgent {
container: harness.container().clone(),
});
let operator = MindsetOperator::new(harness.container().clone(), Arc::new(SupportMindset))
.with_agents(vec![agent_binding(agent).build()]);
harness.adapter().reply("Parece urgente.");
let answer = operator
.call_function("ask_triage", r#"{"input":"cliente enojado"}"#)
.await
.unwrap();
assert_eq!(answer, "Parece urgente.");
}
mod rest_harness {
use super::*;
use wabot_feature_rest_controller::axum::http::StatusCode;
use wabot_feature_rest_controller::axum::Router;
use wabot_feature_rest_controller::{RestError, RestResult};
use wabot_macros::rest_controller;
#[derive(Debug, Serialize, Deserialize, ValidateDerive)]
struct CreateUser {
#[is_not_empty]
name: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct User {
id: String,
name: String,
}
#[derive(Debug, Serialize, Deserialize, ValidateDerive)]
struct GetUser {
id: String,
}
#[singleton]
#[derive(Default)]
struct UserController;
#[rest_controller("/users")]
impl UserController {
#[get("/:id")]
async fn get_one(&self, req: GetUser) -> RestResult<User> {
if req.id == "404" {
return Err(RestError::NotFound("no such user".into()));
}
Ok(User {
id: req.id,
name: "Ada".into(),
})
}
#[post("/")]
async fn create(&self, req: CreateUser) -> RestResult<User> {
Ok(User {
id: "u-1".into(),
name: req.name,
})
}
#[get("/:id/echo-header")]
async fn echo_header(&self, _req: GetUser) -> RestResult<serde_json::Value> {
Ok(json!({ "ok": true }))
}
}
fn harness() -> RestHarness {
let container = Container::new();
wabot_core::register_singletons!(&container, UserController);
RestHarness::new(UserController::register_routes(&container, Router::new()))
}
#[tokio::test]
async fn a_route_answers_with_its_typed_body() {
let response = harness().get("/users/7").send().await;
response.assert_ok();
let user: User = response.json();
assert_eq!(user.id, "7");
assert_eq!(user.name, "Ada");
}
#[tokio::test]
async fn a_json_body_reaches_the_handler() {
let response = harness()
.post("/users")
.json(&json!({ "name": "Grace" }))
.send()
.await;
response.assert_ok();
assert_eq!(response.value()["name"], "Grace");
}
#[tokio::test]
async fn validation_rejects_a_bad_body_the_way_production_does() {
let response = harness()
.post("/users")
.json(&json!({ "name": "" }))
.send()
.await;
assert_eq!(response.status, StatusCode::BAD_REQUEST);
assert!(response.body.contains("name"), "{}", response.body);
}
#[tokio::test]
async fn a_handler_error_maps_to_its_status() {
let response = harness().get("/users/404").send().await;
response.assert_status(StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn an_unknown_route_is_a_404() {
harness()
.get("/nope")
.send()
.await
.assert_status(StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn the_framework_layers_are_in_the_stack() {
harness().get("/users/7/").send().await.assert_ok();
let response = harness().get("/users/7").send().await;
assert!(
response.header("x-request-id").is_some(),
"the request-context layer should have run: {:?}",
response.headers
);
}
#[tokio::test]
async fn a_default_header_rides_on_every_request() {
let authed = harness().with_bearer("t0ken");
let response = authed.get("/users/1/echo-header").send().await;
response.assert_ok();
harness()
.get("/users/1/echo-header")
.send()
.await
.assert_ok();
}
#[tokio::test]
async fn query_parameters_are_encoded() {
let response = harness().get("/users/7").query("q", "a b&c=d").send().await;
response.assert_ok();
}
#[tokio::test]
#[should_panic(expected = "got HTTP 404")]
async fn a_failed_json_decode_shows_what_came_back_instead() {
let response = harness().get("/users/404").send().await;
let _: User = response.json();
}
}
mod async_harness {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use wabot_core::audit::{audit_actor, set_audit_actor, AuditActor};
use wabot_core::log_context::{run_with_log_context, LogContext};
use wabot_feature_async::{
AsyncError, CommandData, CommandHandlerEntry, CommandInvokeFn, JobOptions,
};
#[derive(Debug, Serialize, Deserialize)]
struct SendEmail {
to: String,
}
impl CommandData for SendEmail {
const COMMAND_NAME: &'static str = "send-email";
}
#[derive(Default)]
struct Observed {
runs: AtomicUsize,
actor: parking_lot::Mutex<Option<AuditActor>>,
}
fn handler(observed: Arc<Observed>, options: JobOptions, fail: bool) -> CommandHandlerEntry {
let invoke: CommandInvokeFn = Arc::new(move |_c, payload| {
let observed = observed.clone();
Box::pin(async move {
let command: SendEmail = serde_json::from_value(payload)
.map_err(|e| AsyncError::Validation(e.to_string()))?;
observed.runs.fetch_add(1, Ordering::SeqCst);
*observed.actor.lock() = audit_actor();
if fail {
return Err(AsyncError::Handler(format!(
"could not mail {}",
command.to
)));
}
Ok(())
})
});
CommandHandlerEntry {
command_name: SendEmail::COMMAND_NAME.to_string(),
options,
dedup: None,
invoke,
}
}
#[tokio::test]
async fn a_command_runs_and_the_job_records_success() {
let observed = Arc::new(Observed::default());
let harness = AsyncHarness::builder()
.command(handler(observed.clone(), JobOptions::default(), false))
.build();
let finished = harness
.execute(&SendEmail {
to: "ada@example.com".into(),
})
.await;
finished.assert_succeeded();
assert_eq!(observed.runs.load(Ordering::SeqCst), 1);
assert_eq!(harness.jobs().await.len(), 1);
}
#[tokio::test]
async fn a_failing_handler_records_the_error_on_the_job() {
let observed = Arc::new(Observed::default());
let harness = AsyncHarness::builder()
.command(handler(observed, JobOptions::default(), true))
.build();
let finished = harness
.execute(&SendEmail {
to: "ada@example.com".into(),
})
.await;
assert!(!finished.succeeded());
assert!(
finished.error().unwrap().contains("could not mail"),
"{:?}",
finished.error()
);
assert!(finished.run_error.is_none(), "the runner itself was fine");
}
#[tokio::test]
async fn a_failure_with_retries_configured_schedules_another_attempt() {
let observed = Arc::new(Observed::default());
let harness = AsyncHarness::builder()
.command(handler(
observed,
JobOptions {
retry_delays_seconds: Some(vec![30, 300]),
..Default::default()
},
true,
))
.build();
let finished = harness
.execute(&SendEmail {
to: "ada@example.com".into(),
})
.await;
assert!(
finished.retry_at_ms().is_some(),
"a retry should be queued rather than the job failing outright"
);
assert!(finished.attempts() >= 1);
}
#[tokio::test]
async fn the_handler_runs_with_the_dispatchers_identity() {
let observed = Arc::new(Observed::default());
let harness = AsyncHarness::builder()
.command(handler(observed.clone(), JobOptions::default(), false))
.build();
run_with_log_context(LogContext::new(), async {
set_audit_actor(AuditActor::user().with_id("u-1"));
harness
.execute(&SendEmail {
to: "ada@example.com".into(),
})
.await
.assert_succeeded();
})
.await;
let actor = observed.actor.lock().clone().expect("an actor");
assert_eq!(actor.id.as_deref(), Some("u-1"));
}
#[tokio::test]
#[should_panic(expected = "no handler registered for command 'send-email'")]
async fn running_an_unhandled_command_says_so() {
let harness = AsyncHarness::builder().build();
harness
.execute(&SendEmail {
to: "ada@example.com".into(),
})
.await;
}
}
mod ui_harness {
use super::*;
use wabot_feature_rest_controller::axum::http::StatusCode;
use wabot_feature_ui_controller::island::{island_host, serialize_props};
use wabot_feature_ui_controller::scope;
use wabot_feature_ui_controller::{ui_router, UiError, UiResult, ViewBody};
use wabot_macros::ui_controller;
#[derive(Debug, Serialize, Deserialize, ValidateDerive)]
struct AddNote {
#[is_not_empty]
text: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct Added {
total: usize,
}
#[derive(Serialize)]
struct FormProps {
action_url: String,
}
#[singleton]
#[derive(Default)]
struct SiteController;
#[ui_controller("/", app, layout)]
impl SiteController {
fn layout(&self, body: ViewBody) -> ViewBody {
ViewBody::raw(format!(
"<header>wabot</header>{}",
wabot_feature_ui_controller::island::outlet_host(body.as_str())
))
}
#[view("/notes", title = "Notes")]
async fn notes(&self) -> UiResult<ViewBody> {
let props = serialize_props(&FormProps {
action_url: "/_action/add_note".into(),
});
scope::record_island(wabot_feature_ui_controller::island::IslandRef::new(
"notes-form",
props.clone(),
));
Ok(ViewBody::raw(format!(
"<h1>Notes</h1>{}",
island_host("notes-form", &props, "<form></form>")
)))
}
#[action("add_note")]
async fn add_note(&self, req: AddNote) -> UiResult<Added> {
if req.text == "boom" {
return Err(UiError::Client {
status: 400,
message: "no".into(),
});
}
Ok(Added {
total: req.text.len(),
})
}
}
fn harness() -> UiHarness {
let container = Container::new();
wabot_core::register_singletons!(&container, SiteController);
UiHarness::new(SiteController::register_ui_routes(&container, ui_router()))
}
#[tokio::test]
async fn a_view_renders_a_document_through_its_layout() {
let page = harness().get("/notes").await;
page.assert_ok().assert_contains("<h1>Notes</h1>");
assert!(
page.contains("<header>wabot</header>"),
"the layout should have wrapped it: {}",
page.html()
);
assert!(page.contains("<title>Notes</title>"), "{}", page.html());
}
#[tokio::test]
async fn an_island_host_carries_its_id_and_props() {
let page = harness().get("/notes").await;
assert!(page.has_island("notes-form"));
assert_eq!(page.islands(), vec!["notes-form"]);
assert_eq!(
page.island_props("notes-form"),
Some(json!({ "action_url": "/_action/add_note" })),
"props must survive the attribute escaping"
);
assert_eq!(
page.island_props("not-there"),
None,
"an absent island is distinguishable from one with no props"
);
}
#[tokio::test]
async fn an_action_runs_and_answers_json() {
let response = harness()
.action("/", "add_note", &json!({ "text": "hola" }))
.await;
response.assert_ok();
assert_eq!(response.value()["total"], 4);
}
#[tokio::test]
async fn an_action_error_keeps_its_status() {
let response = harness()
.action("/", "add_note", &json!({ "text": "boom" }))
.await;
response.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn an_action_validates_its_body_like_production() {
let response = harness()
.action("/", "add_note", &json!({ "text": "" }))
.await;
assert_eq!(response.status, StatusCode::BAD_REQUEST);
assert!(response.body.contains("text"), "{}", response.body);
}
#[tokio::test]
async fn a_boosted_navigation_returns_a_fragment_not_a_document() {
let fragment = harness().navigate("/notes").await;
fragment.assert_ok();
assert!(fragment.html().contains("<h1>Notes</h1>"));
assert!(
!fragment.html().contains("<header>wabot</header>"),
"the shell must not be inside the fragment: {}",
fragment.html()
);
assert_eq!(fragment.title().as_deref(), Some("Notes"));
}
#[tokio::test]
async fn the_client_runtime_is_served() {
let response = harness().client_runtime().await;
response.assert_ok();
assert!(
response.body.contains("island"),
"the runtime should be the real client.js"
);
}
}