tests/robot_chat.rs
//! **Robot-UI chat test** (EPIC H / #45) — drive nornir's viz 💬 Chat pane
//! through the SAME live-drive control channel the `viz.click` MCP tool uses
//! (`apply_command(VizCommand{ tab / set_field / click_id })`), INSERT a chat
//! prompt, and ASSERT a non-empty model response comes back — read as DATA out of
//! `state_json()["chat"]` (the robot-UI introspection contract, LAW #6).
//!
//! Two arms:
//! 1. **Always-on** — the full insert→send→read-back path with the always-present
//! `mock` generator, so the surface + wiring are proven on any machine
//! (deterministic, no weights).
//! 2. **Real local model** — when a candle GGUF is cached under the `/mnt/work4t`
//! models root (this box), the SAME robot path drives a genuinely loaded local
//! model (Qwen2.5-0.5B-Instruct, Q4_K_M) and asserts a real completion. NOT a
//! fake: the reply is produced by an in-process candle decode of the GGUF.
//!
//! `assert_emit!` records each verdict as a `nornir-testmatrix` functional-status
//! row so the new chat surface feeds the test matrix (repo emit-doctrine).
#![cfg(all(feature = "viz", feature = "gen-candle"))]
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use nornir::assert_emit;
use nornir::viz::UrdrThreadsApp;
use nornir::viz::control::{VizCommand, VizField};
use nornir::warehouse::generator::candle;
/// A fresh local-mode viz app on its OWN warehouse dir (so parallel tests never
/// contend on the redb catalog lock). Skips the durable action-log sink for the
/// same reason the R6 viz test does.
fn fresh_app() -> UrdrThreadsApp {
// TODO: Audit that the environment access only happens in single-threaded code.
unsafe { std::env::set_var("NORNIR_VIZ_NO_DURABLE_ACTIONLOG", "1") };
static SEQ: AtomicU64 = AtomicU64::new(0);
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let root = std::env::temp_dir().join(format!("nornir-robot-chat-{}-{n}", std::process::id()));
let _ = std::fs::create_dir_all(&root);
UrdrThreadsApp::with_repos(
root,
"nornir".to_string(),
PathBuf::new(),
vec!["nornir".to_string()],
)
}
/// One live-drive command (the exact shape the `viz.click` MCP tool writes).
fn tab(name: &str) -> VizCommand {
VizCommand {
tab: Some(name.to_string()),
..Default::default()
}
}
fn set_field(name: &str, value: &str) -> VizCommand {
VizCommand {
set_field: Some(VizField {
name: name.to_string(),
value: value.to_string(),
}),
..Default::default()
}
}
fn click(key: &str) -> VizCommand {
VizCommand {
click_id: Some(key.to_string()),
..Default::default()
}
}
/// Drive the Chat pane end-to-end through the live-drive channel: open the tab,
/// point it at `model_spec`, cap the reply, type `prompt`, and Send. Returns the
/// `state_json()["chat"]` slice the robot reads back.
fn drive_chat(app: &mut UrdrThreadsApp, model_spec: &str, prompt: &str) -> serde_json::Value {
app.apply_command(&tab("Chat"));
app.apply_command(&set_field("chat.model", model_spec));
app.apply_command(&set_field("chat.max_tokens", "24"));
app.apply_command(&set_field("chat.input", prompt));
// chat.send runs the ACTIVE model synchronously, so the reply is in the very
// next state dump — the robot-UI insert→assert contract.
app.apply_command(&click("chat.send"));
app.state_json()["chat"].clone()
}
/// Is the given candle preset cached under the local `/mnt/work4t` models root?
fn real_model_present(subdir: &str, gguf_file: &str) -> bool {
let dir = candle::models_root().join(subdir);
dir.join(gguf_file).exists() && dir.join("tokenizer.json").exists()
}
#[test]
fn robot_chat_inserts_a_prompt_and_asserts_a_response() {
// ── Arm 1: always-on, backend-agnostic (mock) ────────────────────────────────
let mut app = fresh_app();
let chat = drive_chat(&mut app, "mock", "ping? please reply");
let responded = chat["responded"].as_bool().unwrap_or(false);
let reply = chat["last_response"].as_str().unwrap_or("");
assert_emit!(
"viz Chat pane",
"robot_insert_send_returns_a_response",
responded && !reply.is_empty(),
"backend=mock responded={responded} reply={reply:?} turns={}",
chat["turn_count"]
);
// The transcript recorded one user + one assistant turn, and the MCP-to-nornir
// client config is discoverable in the SAME dump (stdio relay, not a URL).
assert_eq!(chat["message_count"], 2, "one user + one assistant turn");
assert_emit!(
"viz Chat pane",
"mcp_to_nornir_config_is_discoverable",
chat["mcp"]["transport"] == "stdio"
&& chat["mcp"]["config"]["mcpServers"]["nornir"]["command"] == "nornir-mcp",
"transport={} command={}",
chat["mcp"]["transport"],
chat["mcp"]["config"]["mcpServers"]["nornir"]["command"]
);
// ── Arm 2: REAL local model when cached under /mnt/work4t (this box) ─────────
let spec = "candle:qwen2.5-0.5b";
if real_model_present("qwen2.5-0.5b-instruct", "qwen2.5-0.5b-instruct-q4_k_m.gguf") {
let mut app = fresh_app();
let chat = drive_chat(&mut app, spec, "Reply with a short friendly greeting.");
let responded = chat["responded"].as_bool().unwrap_or(false);
let reply = chat["last_response"].as_str().unwrap_or("").to_string();
let device = chat["model"]["device"].as_str().unwrap_or("?");
let path = chat["model"]["resolved_path"].as_str().unwrap_or("?");
let available = chat["model"]["available"].as_bool().unwrap_or(false);
eprintln!(
"[robot_chat] REAL local model: spec={spec} available={available} device={device}\n\
[robot_chat] path={path}\n[robot_chat] reply={reply:?}"
);
assert_emit!(
"viz Chat pane",
"robot_real_local_model_returns_a_completion",
responded && !reply.trim().is_empty(),
"spec={spec} device={device} available={available} reply={reply:?}"
);
// The active model resolves to an on-disk GGUF under the /mnt/work4t cache.
assert!(
path.contains("/mnt/work4t") || path.contains("nornir-models"),
"active model backed by the local models cache, got path={path}"
);
} else {
eprintln!(
"[robot_chat] real-model arm SKIPPED: {spec} not cached under {} \
(mock arm already proved the robot insert→response path)",
candle::models_root().display()
);
}
}