Ctrl K
ComunidadGuías…

test_brain_dispatch.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""brain() dispatches on the brain_backend config key.

test_pipeline_bridge.py proves the full bridge chain through build_brain(). This
proves the step before that: config alone selects the backend and transport, so a
real deployment enables the persistent-session brain by editing config.local.json,
with no code change. No models and no bot-spren CLI are needed — config selects the
sim transport and points its paths at temp files, so the repo's own config is
untouched.
"""

from __future__ import annotations

import json
import sys
import tempfile
from pathlib import Path
from types import SimpleNamespace

import brain_bridge
import pipeline
from sim_persona import SimPersona

results: list[tuple[bool, str]] = []


def check(ok: bool, detail: str) -> None:
    results.append((ok, detail))
    print(f"  [{'PASS' if ok else 'FAIL'}] {detail}")


def _write(path: Path, cfg: dict) -> None:
    path.write_text(json.dumps(cfg))


def test_routing(d: Path) -> None:
    """brain_backend picks the helper; cli is the default for a bare config."""
    pipeline.CONFIG_PATH = d / "config.json"
    pipeline.LOCAL_CONFIG_PATH = d / "config.local.json"
    pipeline._brain_cli = lambda text, cfg, **kw: "CLI"
    pipeline._brain_bridge = lambda text, cfg: "BRIDGE"

    _write(pipeline.CONFIG_PATH, {"brain_backend": "cli"})
    check(pipeline.brain("hi") == "CLI", "brain_backend=cli routes to the CLI path")

    _write(pipeline.CONFIG_PATH, {"brain_backend": "bridge"})
    check(
        pipeline.brain("hi") == "BRIDGE",
        "brain_backend=bridge routes to the bridge path",
    )

    # A config with no brain_backend key falls back to the DEFAULTS value (cli),
    # so a fresh clone runs standalone.
    _write(pipeline.CONFIG_PATH, {"wake_word": "hey_jarvis"})
    check(pipeline.brain("hi") == "CLI", "missing brain_backend defaults to cli")


def test_bridge_via_config(d: Path) -> None:
    """With only config.local.json set to bridge, brain() answers via the sim."""
    inbox = d / "manual-inbox.jsonl"
    reply = d / "reply.txt"

    # config.json stays minimal; config.local.json enables the bridge. This is the
    # committed-base + gitignored-overlay split a real deployment uses.
    pipeline.CONFIG_PATH = d / "config.json"
    pipeline.LOCAL_CONFIG_PATH = d / "config.local.json"
    _write(pipeline.CONFIG_PATH, {"wake_word": "hey_jarvis"})
    _write(
        pipeline.LOCAL_CONFIG_PATH,
        {
            "brain_backend": "bridge",
            "brain_transport": "sim",
            "brain_persona": "syl",
            "brain_reply_path": str(reply),
            "brain_inbox_path": str(inbox),
            "brain_timeout_s": 5,
            "brain_poll_s": 0.05,
        },
    )

    sim = SimPersona(inbox, reply, poll_s=0.02)
    sim.start()
    try:
        out = pipeline.brain("what is two plus two?")
    finally:
        sim.stop()

    check(
        out == "Two plus two is four.",
        f"config-driven sim transport answered: {out!r}",
    )
    sent = json.loads(inbox.read_text().splitlines()[0])["payload"]
    check(
        sent == f"{pipeline.SYL_VOICE_SYSTEM_PROMPT}\n\nUser: what is two plus two?",
        "the bridge sends Syl the voice dispatcher policy before the transcript",
    )


def test_voice_dispatch_policy() -> None:
    """The voice contract pins both escalation paths and the non-blocking handoff."""
    policy = pipeline.SYL_VOICE_SYSTEM_PROMPT.casefold()
    required = {
        "explicit escalation": "explicitly asks you to hand work off",
        "implicit escalation": "needs tools, multiple steps, or more than a quick answer",
        "ephemeral background worker": "ephemeral subagent in the background",
        "immediate voice-loop return": "do not do delegated work inline or wait",
        "mid-task correction": "follow-up corrections while the subagent runs",
        "spoken result": "summarize each unreported result once",
        "one bridge reply per turn": "never emit a second reply for the original turn",
        "voice-event scope": "apply these instructions only to voice events",
        "confirmation before external change": (
            "do not start the subagent or act until joe confirms"
        ),
        "spoken reply budget": "at most 500 characters",
        "deferred overflow result": "leave other results unreported",
    }
    for behavior, instruction in required.items():
        check(instruction in policy, f"voice policy includes {behavior}")
    check(
        pipeline._bridge_voice_system_prompt("syl") == pipeline.SYL_VOICE_SYSTEM_PROMPT,
        "the Syl bridge persona receives the dispatcher policy",
    )
    check(
        pipeline._bridge_voice_system_prompt("assistant")
        == pipeline.VOICE_SYSTEM_PROMPT,
        "a non-Syl bridge persona keeps the neutral voice policy",
    )
    check(
        pipeline._bridge_voice_system_prompt(None) == pipeline.VOICE_SYSTEM_PROMPT,
        "a null bridge persona falls back to the neutral voice policy",
    )
    check(
        pipeline._bridge_voice_system_prompt(49) == pipeline.VOICE_SYSTEM_PROMPT,
        "a non-string bridge persona falls back to the neutral voice policy",
    )


def test_factory_validation() -> None:
    """Invalid deployment settings stay inside the voice error boundary."""
    cases = (
        ({}, "reply path is not configured"),
        (
            {"brain_reply_path": "/tmp/reply", "brain_transport": "ssh"},
            "host is not configured",
        ),
        (
            {"brain_reply_path": "/tmp/reply", "brain_transport": "sim"},
            "inbox path is not configured",
        ),
        (
            {
                "brain_reply_path": "/tmp/reply",
                "brain_transport": "sim",
                "brain_inbox_path": ["/tmp/inbox"],
            },
            "inbox path is not a filesystem path",
        ),
        (
            {"brain_reply_path": "/tmp/reply", "brain_transport": "carrier-pigeon"},
            "is not supported",
        ),
        (
            {"brain_reply_path": "/tmp/reply", "brain_transport": []},
            "is not supported",
        ),
        (
            {"brain_reply_path": "/tmp/reply", "brain_transport": {}},
            "is not supported",
        ),
    )
    for cfg, expected in cases:
        reply = brain_bridge.build_brain(cfg)("hello")
        check(expected in reply, f"bad bridge config returns a spoken error: {reply!r}")


def test_cli_voice_prompt(real_brain_cli) -> None:
    """The tool-free CLI fallback must not promise a background handoff."""
    captured = {}
    real_run = pipeline.subprocess.run

    def fake_run(*_args, **kwargs):
        captured["prompt"] = kwargs["input"]
        return SimpleNamespace(returncode=0, stdout="Done.\n", stderr="")

    pipeline.subprocess.run = fake_run
    try:
        out = real_brain_cli("file an issue", dict(pipeline.DEFAULTS))
    finally:
        pipeline.subprocess.run = real_run

    check(out == "Done.", f"CLI fallback still returns its reply: {out!r}")
    check(
        captured["prompt"]
        == f"{pipeline.VOICE_SYSTEM_PROMPT}\n\nUser: file an issue\nAssistant:",
        "CLI fallback uses the tool-free voice prompt",
    )
    check(
        "subagent" not in captured["prompt"].casefold(),
        "CLI fallback does not claim it can delegate",
    )


def test_set_wake_word_no_leak(d: Path) -> None:
    """--set-wake-word writes only the base config, never the local overlay."""
    pipeline.CONFIG_PATH = d / "config.json"
    pipeline.LOCAL_CONFIG_PATH = d / "config.local.json"
    _write(pipeline.CONFIG_PATH, {"wake_word": "alexa"})
    _write(
        pipeline.LOCAL_CONFIG_PATH,
        {
            "brain_backend": "bridge",
            "brain_host": "secret-host",
            "brain_reply_path": "/secret/path",
            "brain_persona": "secret-persona",
        },
    )

    pipeline.set_wake_word("hey_jarvis")
    written = json.loads(pipeline.CONFIG_PATH.read_text())

    local_keys = ("brain_backend", "brain_host", "brain_reply_path", "brain_persona")
    leaked = [k for k in local_keys if k in written]
    check(not leaked, f"config.json keeps the local overlay out (leaked: {leaked})")
    check(written.get("wake_word") == "hey_jarvis", "config.json wake_word was updated")
    check(
        pipeline.load_config()["brain_backend"] == "bridge",
        "effective config still reads the local overlay (runtime unaffected)",
    )


def test_set_wake_word_local(d: Path) -> None:
    """--set-wake-word --local writes config.local.json, leaving the base intact."""
    pipeline.CONFIG_PATH = d / "config.json"
    pipeline.LOCAL_CONFIG_PATH = d / "config.local.json"
    _write(pipeline.CONFIG_PATH, {"wake_word": "hey_jarvis"})
    _write(
        pipeline.LOCAL_CONFIG_PATH,
        {"brain_backend": "bridge", "brain_host": "secret-host"},
    )

    pipeline.set_wake_word("alexa", local=True)

    base = json.loads(pipeline.CONFIG_PATH.read_text())
    overlay = json.loads(pipeline.LOCAL_CONFIG_PATH.read_text())
    check(
        base.get("wake_word") == "hey_jarvis",
        "committed config.json default is left untouched by a local set",
    )
    check(
        overlay.get("wake_word") == "alexa",
        "config.local.json carries the local wake-word override",
    )
    check(
        overlay.get("brain_host") == "secret-host",
        "existing config.local.json keys survive the local set",
    )
    check(
        pipeline.load_config()["wake_word"] == "alexa",
        "effective config reads the local wake word (overlay wins)",
    )


def test_set_wake_word_local_malformed_no_clobber(d: Path) -> None:
    """--set-wake-word --local refuses to overwrite a malformed config.local.json."""
    pipeline.CONFIG_PATH = d / "config.json"
    pipeline.LOCAL_CONFIG_PATH = d / "config.local.json"
    _write(pipeline.CONFIG_PATH, {"wake_word": "hey_jarvis"})
    # A truncated overlay an operator left mid-edit, still carrying live settings.
    truncated = '{"brain_host": "secret-host",'
    pipeline.LOCAL_CONFIG_PATH.write_text(truncated)

    raised = False
    try:
        pipeline.set_wake_word("alexa", local=True)
    except ValueError:
        raised = True

    check(raised, "a malformed config.local.json raises instead of clobbering")
    check(
        pipeline.LOCAL_CONFIG_PATH.read_text() == truncated,
        "the malformed overlay is left intact for the operator to fix",
    )


def test_set_wake_word_base_shadowed_by_local(d: Path) -> None:
    """A base set stays shadowed by a local override — the invariant the CLI note keys off."""
    pipeline.CONFIG_PATH = d / "config.json"
    pipeline.LOCAL_CONFIG_PATH = d / "config.local.json"
    _write(pipeline.CONFIG_PATH, {})
    _write(pipeline.LOCAL_CONFIG_PATH, {"wake_word": "alexa"})

    cfg = pipeline.set_wake_word("hey_jarvis", local=False)

    base = json.loads(pipeline.CONFIG_PATH.read_text())
    check(base.get("wake_word") == "hey_jarvis", "the base write lands in config.json")
    check(
        cfg["wake_word"] == "alexa",
        "effective wake word stays the local override, so the CLI flags it as shadowed",
    )


def main() -> int:
    # Snapshot module state so the test restores the repo's real wiring on exit.
    saved = (
        pipeline.CONFIG_PATH,
        pipeline.LOCAL_CONFIG_PATH,
        pipeline._brain_cli,
        pipeline._brain_bridge,
    )
    try:
        with tempfile.TemporaryDirectory(prefix="brain-dispatch-") as tmp:
            d = Path(tmp)
            test_routing(d)
            test_voice_dispatch_policy()
            test_factory_validation()
            # Restore the real helpers before the live-ish bridge test.
            pipeline._brain_cli, pipeline._brain_bridge = saved[2], saved[3]
            test_cli_voice_prompt(saved[2])
            test_bridge_via_config(d)
            test_set_wake_word_no_leak(d)
            test_set_wake_word_local(d)
            test_set_wake_word_local_malformed_no_clobber(d)
            test_set_wake_word_base_shadowed_by_local(d)
    finally:
        (
            pipeline.CONFIG_PATH,
            pipeline.LOCAL_CONFIG_PATH,
            pipeline._brain_cli,
            pipeline._brain_bridge,
        ) = saved

    n_pass = sum(1 for ok, _ in results if ok)
    print(f"=== {n_pass}/{len(results)} checks passed ===")
    return 0 if n_pass == len(results) else 1


if __name__ == "__main__":
    sys.exit(main())