"""Tool-using playbook loop — the read-only MVP. Run from nexus-core/ with the Promethean venv active: pytest -q Hermetic: a fake manager stands in for Ollama, so no network/model is needed. Guards the two pieces that would silently break the feature: the allowlist filter and the tool-call loop's terminate-on-content behaviour. """ import asyncio import json from synapse import tools from synapse.chat import _run_tool_loop def test_schemas_for_drops_unknown_names(): schemas = tools.schemas_for(["search_memory", "not_a_tool"]) names = [s["function"]["name"] for s in schemas] assert names == ["search_memory"] assert tools.schemas_for([]) == [] async def _drain(gen): return [s async for s in gen] def test_remember_writes_a_memory_fact(tmp_path, monkeypatch): # The `remember` action tool persists a fact through the store. import synapse.memory.store as store_mod from synapse.memory.store import PersistentMemoryStore fresh = PersistentMemoryStore(tmp_path / "m.db") monkeypatch.setattr(tools, "store", fresh) out = asyncio.run(tools.dispatch("remember", {"text": "user likes tea", "section": "Prefs"})) assert "user likes tea" in out assert any(it.text == "user likes tea" for it in fresh.all()) def test_action_tools_registered(): for name in ("web_search", "fetch_url", "remember"): assert name in tools.REGISTRY names = [s["function"]["name"] for s in tools.schemas_for(["web_search", "remember", "nope"])] assert names == ["web_search", "remember"] class _ActionManager: """Returns a `remember` (action) tool_call once, then plain content.""" def __init__(self): self.n = 0 async def chat(self, **_): self.n += 1 if self.n == 1: return {"role": "assistant", "tool_calls": [{"function": {"name": "remember", "arguments": {"text": "x"}}}]} return {"role": "assistant", "content": "done"} def _drive_with_decision(decision, monkeypatch): from synapse import chat as chatmod async def fake_dispatch(name, args): return "saved-ok" monkeypatch.setattr(tools, "dispatch", fake_dispatch) async def run(): messages = [{"role": "user", "content": "remember x"}] schemas = tools.schemas_for(["remember"]) gen = chatmod._run_tool_loop(_ActionManager(), messages, "m", schemas, None, None, conversation_id="conv", policy="ask") statuses = [] async for s in gen: statuses.append(s) if s.startswith("__approve__"): w = chatmod.pending_approvals["conv"] w["decisions"] = {"remember": decision} w["event"].set() return statuses, messages return asyncio.run(run()) def test_ask_policy_pauses_then_runs_on_approve(monkeypatch): statuses, messages = _drive_with_decision(True, monkeypatch) assert any(s.startswith("__approve__") for s in statuses) # paused for approval assert "__status__remember" in statuses # approved -> ran assert any(m["role"] == "tool" and "saved-ok" in m["content"] for m in messages) def test_ask_policy_skips_on_deny(monkeypatch): statuses, messages = _drive_with_decision(False, monkeypatch) assert any(s.startswith("__approve__") for s in statuses) assert "__status__remember" not in statuses # denied -> never ran assert any(m["role"] == "tool" and "declined" in m["content"] for m in messages) class _ContentJsonActionManager: """Small-model shape: dumps the action call into `content`, no native `tool_calls` field — the lower-confidence path the "allow" bypass must not trust.""" def __init__(self): self.n = 0 async def chat(self, **_): self.n += 1 if self.n == 1: return {"role": "assistant", "content": json.dumps({"name": "remember", "arguments": {"text": "x"}})} return {"role": "assistant", "content": "done"} def test_content_json_action_call_asks_even_under_allow_policy(monkeypatch): """A call recovered by guessing at `content` is weaker evidence than the API's own structured tool_calls field — a model can land on JSON shaped like a call while only meaning to describe one. It must still go through approval even when action_tool_policy is "allow", the default that lets a *native* tool_calls field run unattended.""" from synapse import chat as chatmod async def fake_dispatch(name, args): return "saved-ok" monkeypatch.setattr(tools, "dispatch", fake_dispatch) async def run(): messages = [{"role": "user", "content": "remember x"}] schemas = tools.schemas_for(["remember"]) gen = chatmod._run_tool_loop(_ContentJsonActionManager(), messages, "m", schemas, None, None, conversation_id="conv", policy="allow") statuses = [] async for s in gen: statuses.append(s) if s.startswith("__approve__"): w = chatmod.pending_approvals["conv"] w["decisions"] = {"remember": True} w["event"].set() return statuses statuses = asyncio.run(run()) assert any(s.startswith("__approve__") for s in statuses) assert "__status__remember" in statuses def test_action_tools_gated_by_consent(): allow = ["search_memory", "web_search", "remember", "fetch_url"] on = [s["function"]["name"] for s in tools.schemas_for(allow, allow_actions=True)] off = [s["function"]["name"] for s in tools.schemas_for(allow, allow_actions=False)] assert set(on) == set(allow) # all pass when actions allowed assert off == ["search_memory"] # action tools withheld when not assert tools.is_action("remember") and not tools.is_action("search_memory") class _FakeManager: """Returns a tool_call on the first chat() call, plain content after.""" def __init__(self): self.calls = 0 async def chat(self, **_): self.calls += 1 if self.calls == 1: return { "role": "assistant", "tool_calls": [ {"function": {"name": "search_memory", "arguments": {"query": "gpu"}}} ], } return {"role": "assistant", "content": "here is the answer"} def test_tool_loop_runs_tool_then_stops(monkeypatch): async def fake_dispatch(name, args): assert name == "search_memory" assert args == {"query": "gpu"} return '[{"section": "GPU", "text": "Vega 20 4GB"}]' monkeypatch.setattr(tools, "dispatch", fake_dispatch) messages = [{"role": "user", "content": "what gpu do i have?"}] schemas = tools.schemas_for(["search_memory"]) statuses = asyncio.run(_drain( _run_tool_loop(_FakeManager(), messages, "m", schemas, None, None) )) # heartbeat + one status sentinel per tool run assert statuses == ["__status__tools", "__status__search_memory"] # messages mutated in place: user -> assistant(tool_calls) -> tool(result); # the final content turn is NOT appended (the streaming turn regenerates it). assert [m["role"] for m in messages] == ["user", "assistant", "tool"] assert "Vega 20" in messages[-1]["content"] def test_tool_loop_degrades_when_model_returns_no_dict(): class _NoToolManager: async def chat(self, **_): return None # model can't do tools / errored messages = [{"role": "user", "content": "hi"}] before = list(messages) statuses = asyncio.run(_drain(_run_tool_loop(_NoToolManager(), messages, "m", [{}], None, None))) assert statuses == ["__status__tools"] # heartbeat only; no tool ran assert messages == before # untouched -> falls back to a plain stream def test_read_file_stays_inside_the_repo(): """The repo-file tools are the fix for the model inventing paths like `nexus/nlp.py`; the deny-list is what keeps them from reading secrets.""" import json def read(p): return asyncio.run(tools._read_file(p)) assert "escapes" in read("../../etc/passwd") # a leading slash is treated as repo-relative, so it lands nowhere real assert "root:" not in read("/etc/passwd") assert "required" in read("") # private data and heavy trees are refused even though they're in-repo for denied in ("synapse/memory/memory.db", ".git/config", "Promethean/pyvenv.cfg"): assert "not readable" in read(denied), denied assert "does not exist" in read("nexus/nlp.py") assert "PROJECT_ROOT" in json.loads(read("synapse/nexus_config.py"))["content"] def test_list_files_globs_the_repo_without_leaking_denied_paths(): import json hits = json.loads(asyncio.run(tools._list_files("synapse/**/*"))) assert "synapse/main.py" in hits assert not [h for h in hits if h.endswith(".db") or "__pycache__" in h], hits def test_routed_reference_playbook_contributes_its_tools(tmp_path, monkeypatch): """A reference playbook routed into the prompt must bring its tools with it. Without this the model reads instructions like "you can read the codebase" while being advertised zero tools — and narrates tool calls it never made.""" from synapse.main import _route_playbooks from synapse.playbooks.store import PlaybookFileStore, PlaybookItem # Own store, not data/playbooks: the live set is the operator's, and a # published clone ships different playbooks - this asserted on data that # travels with one machine. store = PlaybookFileStore(tmp_path) store.add_playbook(PlaybookItem(id="main", title="Main", goal="g", instructions="i", order=0)) store.add_playbook(PlaybookItem(id="dev", title="NexusOS Developer", goal="g", instructions="You can read the codebase.", order=1, tags=["synapse", "backend"], tools=["read_file", "list_files"])) monkeypatch.setattr("synapse.playbook_manager.playbook_store", store) import synapse.playbook_manager as pm routed = _route_playbooks("why is the memory endpoint in synapse returning 500", pm.get_context_playbooks()) names = {pb.title for pb in routed} assert "NexusOS Developer" in names, names granted = {t for pb in routed for t in (pb.tools or [])} assert {"read_file", "list_files"} <= granted, granted # none of them are action tools, so they survive the default policy (off) assert tools.schemas_for(sorted(granted), allow_actions=False) def test_standing_schemas_include_render_preview(): names = [s["function"]["name"] for s in tools.standing_schemas()] assert names == ["render_preview"] assert "render_preview" in tools.STANDING_TOOLS assert not tools.is_action("render_preview") assert tools.wants_render_preview("visualize Collatz with a chart") assert not tools.wants_render_preview("what's the weather vibe today") def test_render_preview_packages_markup_without_grading_its_quality(): markup = """
""" out = json.loads(asyncio.run(tools.dispatch("render_preview", { "lang": "html", "title": "Demo", "markup": markup, }))) assert out["ok"] is True assert markup in out["fence"] assert "issues" not in out assert "scaffold" not in out def test_render_preview_accepts_canvas_that_plots(): good = """ """ out = json.loads(asyncio.run(tools.dispatch("render_preview", { "lang": "html", "markup": good, "purpose": "line plot of an iterative sequence", }))) assert out["ok"] is True assert out["fence"].startswith("```html\n") assert "getContext" in out["fence"] assert out.get("repaired") is not True def test_code_that_throws_is_left_to_the_previews_own_error_channel(): """This markup is broken twice over: getContext() is assigned to `canvas` but drawn with `ctx`, and collatz() is called as coll(). Both used to be rejected here by regex. Both now reach the browser, which reports them precisely — verified against the real preview: "Uncaught ReferenceError: ctx is not defined (line 4)" "Uncaught ReferenceError: coll is not defined (line 5)" Static guessing at runtime failures only ever caught the spellings someone anticipated; the error channel catches every one of them and carries a line number.""" broken_at_runtime = """ """ out = json.loads(asyncio.run(tools.dispatch("render_preview", { "lang": "html", "purpose": "interactive sequence plot", "markup": broken_at_runtime, }))) assert out["ok"] is True, out.get("issues") def test_no_sequence_render_seed_helper(): assert not hasattr(tools, "sequence_render_seed") _FRONTEND_REGISTRY = ("interface", "web", "src", "preview", "languages.js") def _frontend_preview_langs() -> list[str]: """Top-level keys of PREVIEW_LANGS in the frontend's preview registry.""" import re from pathlib import Path src = Path(__file__).resolve().parents[1].joinpath(*_FRONTEND_REGISTRY) text = src.read_text(encoding="utf-8") body = re.search(r"^export const PREVIEW_LANGS = \{\n(.*?)^\};", text, re.S | re.M) assert body, f"could not find a PREVIEW_LANGS object literal in {src}" return re.findall(r"^ (\w+):", body.group(1), re.M) def test_preview_langs_match_the_frontend_registry(): """The render window is two registries — synapse/tools.py validates a language, interface/web/src/Markdown.jsx renders it — and a language present in only one degrades silently: the model emits a fence the UI shows as a plain code block, or the UI offers a preview the tool refuses to produce. Nothing at runtime couples them, so this is what keeps them in step.""" # Plain ASCII in the message: this is read off a Windows console, where # pytest's output encoding mangles non-ASCII into replacement characters. assert _frontend_preview_langs() == list(tools.PREVIEW_LANGS), ( "PREVIEW_LANGS differs between synapse/tools.py and " "interface/web/src/Markdown.jsx - add the language to both." ) def test_preview_lang_enum_is_derived_not_repeated(): schema, _ = tools.REGISTRY["render_preview"] enum = schema["function"]["parameters"]["properties"]["lang"]["enum"] assert enum == list(tools.PREVIEW_LANGS) def test_render_preview_rejects_unknown_lang(): out = json.loads(asyncio.run(tools.dispatch("render_preview", { "lang": "python", "markup": "print('hi')" * 5, }))) assert out["ok"] is False assert "lang must be" in out["error"] def test_render_preview_accepts_a_jsx_component(): good = """export default function Counter() { const [n, setN] = useState(0); return (
'
out = json.loads(asyncio.run(tools.dispatch("render_preview", {
"lang": "html", "markup": markup,
})))
assert out["ok"] is True
assert markup in out["fence"]
assert "issues" not in out
def test_every_preview_language_hints_for_itself():
for lang in tools.PREVIEW_LANGS:
assert lang in tools._RENDER_HINTS, lang
def test_normal_tool_results_reach_streaming_turn():
"""Flatten Ollama's tool roles without discarding the retrieved data."""
from synapse.chat import _strip_internal_turns
request = {"role": "user", "content": "what GPU do I have?"}
kept = _strip_internal_turns([
request,
{"role": "assistant", "content": "", "tool_calls": [{
"function": {"name": "search_memory", "arguments": {"query": "GPU"}},
}]},
{"role": "tool", "content": '[{"text":"Vega 20 4GB"}]'},
])
assert kept[-1] == request
assert "Vega 20 4GB" in kept[-2]["content"]
assert all(m.get("role") != "tool" and not m.get("tool_calls") for m in kept)
def test_render_preview_leaves_jsx_runtime_judgment_to_the_browser():
sources = (
"const x = 1;\nconsole.log(x);\n// nothing to mount",
'import { motion } from "framer-motion"; export default () => count is {n} right now
; }""" out = json.loads(asyncio.run(tools.dispatch("render_preview", { "lang": "jsx", "markup": src, }))) assert out["ok"] is True, out.get("issues") def test_render_preview_still_rejects_missing_markup(): out = json.loads(asyncio.run(tools.dispatch("render_preview", { "lang": "tsx", "markup": "", }))) assert out["ok"] is False assert "markup is required" in out["error"] assert "scaffold" not in out def test_coerce_tool_calls_from_content_json(): from synapse.chat import _coerce_tool_calls # Structured field wins. structured = {"role": "assistant", "tool_calls": [ {"function": {"name": "get_time", "arguments": {}}} ]} assert _coerce_tool_calls(structured)[0]["function"]["name"] == "get_time" # Small models dump a complete call into content. content_call = { "role": "assistant", "content": '{"name":"render_preview","arguments":{"lang":"svg","markup":""}}', } calls = _coerce_tool_calls(content_call, {"render_preview"}) assert len(calls) == 1 assert calls[0]["function"]["name"] == "render_preview" assert calls[0]["function"]["arguments"]["lang"] == "svg" # JSON quoted as part of an explanation is output, not an instruction to # execute a tool (especially important for action tools such as remember). embedded = { "role": "assistant", "content": ( 'For example: {"name":"remember","arguments":{"text":"do not save"}} ' "is the tool-call shape." ), } assert _coerce_tool_calls(embedded, {"remember"}) == [] # Even a whole JSON object cannot call a tool that was not advertised. assert _coerce_tool_calls(content_call, {"search_memory"}) == [] def test_tool_loop_runs_content_json_tool_call(monkeypatch): """qwen-style: first turn returns content-JSON tool call, second returns text.""" from synapse import chat as chatmod class _ContentJsonManager: def __init__(self): self.n = 0 async def chat(self, **_): self.n += 1 if self.n == 1: return { "role": "assistant", "content": json.dumps({ "name": "render_preview", "arguments": { "lang": "svg", "markup": ( '' ), }, }), } return {"role": "assistant", "content": "done"} statuses, messages = asyncio.run(_drain_with_messages( _ContentJsonManager(), "m", tools.standing_schemas(), user="draw a circle", )) assert any(s == "__status__render_preview" for s in statuses) tool_msgs = [m for m in messages if m.get("role") == "tool"] assert tool_msgs assert json.loads(tool_msgs[0]["content"])["ok"] is True def test_tool_loop_does_not_inject_a_render_preview_nudge(): class _SkipThenCall: def __init__(self): self.n = 0 async def chat(self, **_): self.n += 1 if self.n == 1: return {"role": "assistant", "content": "Sure, here is a chart in prose."} if self.n == 2: return { "role": "assistant", "tool_calls": [{ "function": { "name": "render_preview", "arguments": { "lang": "svg", "markup": ( '' ), }, } }], } return {"role": "assistant", "content": "done"} statuses, messages = asyncio.run(_drain_with_messages( _SkipThenCall(), "m", tools.standing_schemas(), user="Visualize the Collatz conjecture with an interactive chart", )) assert statuses == ["__status__tools"] assert len(messages) == 1 assert messages[0]["content"].startswith("Visualize") async def _drain_with_messages(manager, model, schemas, user="draw a circle"): messages = [{"role": "user", "content": user}] statuses = await _drain( _run_tool_loop(manager, messages, model, schemas, None, None) ) return statuses, messages