Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

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()
        );
    }
}