"""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 import code_run 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) 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_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_rejects_canvas_that_never_draws(): bad = """ """ out = json.loads(asyncio.run(tools.dispatch("render_preview", { "lang": "html", "title": "Demo", "markup": bad, }))) assert out["ok"] is False joined = " ".join(out.get("issues", [])) assert "draw" in joined.lower() or "getcontext" in joined.lower() assert "scaffold" not in out # withheld on a first rejection def test_render_preview_rejects_forty_by_forty_stub_with_scaffold(): # Tiny stubs fail critique; tool returns fix hints + generic scaffold — not a # canned Collatz/Recamán demo. bad = """ """ out = json.loads(asyncio.run(tools.dispatch("render_preview", { "lang": "html", "markup": bad, "purpose": "interactive plot demo", }))) assert out["ok"] is False assert out.get("repaired") is not True assert "fence" not in out or not out.get("fence") assert "scaffold" not in out # withheld on a first rejection assert "issues" 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. What stays in the critique is the opposite case — markup that runs perfectly and still isn't a visualization.""" 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_render_preview_rejects_decorative_svg_without_guessing_algorithm(): bad = """
""" out = json.loads(asyncio.run(tools.dispatch("render_preview", { "lang": "html", "title": "Unknown sequence", "purpose": "interactive sequence demo", "markup": bad, }))) assert out["ok"] is False assert out.get("repaired") is not True blob = json.dumps(out).lower() assert "recaman" not in blob and "collatz" not in blob assert "scaffold" not in out # withheld on a first rejection 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." ) _FRONTEND_RUN_REGISTRY = ("interface", "web", "src", "preview", "run-langs.js") def _frontend_registry_keys(parts: tuple, name: str) -> list: """Top-level keys of a `export const = {...}` object literal.""" import re from pathlib import Path src = Path(__file__).resolve().parents[1].joinpath(*parts) text = src.read_text(encoding="utf-8") body = re.search(rf"^export const {name} = \{{\n(.*?)^\}};", text, re.S | re.M) assert body, f"could not find a {name} object literal in {src}" return re.findall(r"^ (\w+):", body.group(1), re.M) def test_run_langs_match_the_frontend_registry(): """Same failure mode as the preview registries, one track over: a language the backend can run but the frontend does not know about renders as raw JSON in the chat, and one the frontend labels but the backend refuses produces a tool error the user never asked for. Nothing at runtime couples them.""" assert _frontend_registry_keys(_FRONTEND_RUN_REGISTRY, "RUN_LANGS") == list( code_run.RUN_LANGS ), ( "RUN_LANGS differs between synapse/code_run.py and " "interface/web/src/preview/run-langs.js - add the language to both." ) def test_run_fence_tag_matches_the_frontend(): """The tag is the handshake: run_snippet emits it, Markdown.jsx dispatches on it. A mismatch shows the JSON envelope to the user as a code block.""" import re from pathlib import Path src = Path(__file__).resolve().parents[1].joinpath(*_FRONTEND_RUN_REGISTRY) m = re.search(r'export const RUN_FENCE_LANG = "([^"]+)"', src.read_text(encoding="utf-8")) assert m and m.group(1) == tools._RUN_FENCE_LANG def test_run_lang_enum_is_derived_not_repeated(): schema, _ = tools.REGISTRY["run_snippet"] enum = schema["function"]["parameters"]["properties"]["lang"]["enum"] assert enum == list(code_run.RUN_LANGS) def test_run_snippet_is_an_action_tool(): """It executes code on the host, so action_tool_policy has to gate it. Slipping into STANDING_TOOLS (where render_preview lives, ungated) would make every 'run this' a subprocess with no consent step anywhere.""" assert tools.is_action("run_snippet") assert "run_snippet" not in tools.STANDING_TOOLS assert "run_snippet" in tools.CUED_ACTION_TOOLS # ...and withholding actions has to actually withhold it. assert tools.schemas_for(["run_snippet"], allow_actions=False) == [] def test_wants_code_run_needs_a_verb_not_a_language(): """A language name must not arm the run track. `python` in _RUN_HINTS would drag every mention of the language into a non-stream tool round - the exact 'stuck thinking' problem that kept render_preview off by default.""" assert tools.wants_code_run("run this and show me the output") assert tools.wants_code_run("does this compile?") assert not tools.wants_code_run("write me a python function that sorts a list") assert not tools.wants_code_run("explain how rust ownership works") def test_run_snippet_rejects_a_preview_language(): out = json.loads(asyncio.run(tools.dispatch("run_snippet", { "lang": "html", "source": "

hello there

", }))) assert out["ok"] is False assert "render_preview" in out["error"] def test_run_snippet_fence_survives_backticks_in_the_source(): """A backtick in the source would close the ```nexus-run fence early, and the rest of the envelope would spill into the chat as prose.""" out = json.loads(asyncio.run(tools.dispatch("run_snippet", { "lang": "python", "source": "s = '``` still inside'\nprint(len(s))", }))) assert out["ok"] is True, out body = out["fence"].split("\n", 1)[1].rsplit("\n", 1)[0] assert "```" not in body assert json.loads(body)["source"].startswith("s = '```") def test_run_snippet_reports_a_program_that_fails(): """A non-zero exit is a successful run, not a tool failure: its stderr is the answer. Reporting ok=False here would send the model into a retry loop over a program that did exactly what it was asked to demonstrate.""" out = json.loads(asyncio.run(tools.dispatch("run_snippet", { "lang": "python", "source": "import sys\nprint('before')\nsys.exit(2)", }))) assert out["ok"] is True assert out["exit_code"] == 2 assert "before" in out["stdout"] def test_run_snippet_escalates_a_scaffold_on_retry(): """Same discipline as render_preview: first reject is issues-only; second gets a pattern. _attempt is supplied by the tool loop.""" first = json.loads(asyncio.run(tools.dispatch("run_snippet", { "lang": "python", "source": "import socket\nprint(1)", "_attempt": 0, }))) assert first["ok"] is False assert "scaffold" not in first second = json.loads(asyncio.run(tools.dispatch("run_snippet", { "lang": "python", "source": "import socket\nprint(1)", "_attempt": 1, }))) assert second["ok"] is False assert "scaffold" in second and "print" in second["scaffold"] 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": "jsx", "markup": good, "purpose": "interactive counter", }))) assert out["ok"] is True, out.get("issues") assert out["fence"].startswith("```jsx\n") def test_interactive_ui_needs_no_canvas_but_a_chart_does(): """Forms and calculators are interactive through DOM elements in either HTML or JSX; only a request claiming to be a chart needs a drawing surface.""" component = """export default function Form() { const [name, setName] = useState(""); return ; }""" ok = json.loads(asyncio.run(tools.dispatch("render_preview", { "lang": "jsx", "markup": component, "purpose": "an interactive demo", }))) assert ok["ok"] is True, ok.get("issues") bad = json.loads(asyncio.run(tools.dispatch("render_preview", { "lang": "jsx", "markup": component, "purpose": "a chart of the results", }))) assert bad["ok"] is False assert any("canvas" in i for i in bad["issues"]) html = """ 4 """ html_ok = json.loads(asyncio.run(tools.dispatch("render_preview", { "lang": "html", "markup": html, "purpose": "an interactive calculator demo", }))) assert html_ok["ok"] is True, html_ok.get("issues") def test_scaffold_is_withheld_until_the_model_has_failed_twice(): """A complete, styled, runnable document handed to a struggling model gets pasted rather than adapted — and then persists in the conversation and comes back as retrieved context for later requests, carrying its example domain with it. A transcript showed this scaffold's CSS reappearing verbatim in an answer to an unrelated prompt, in a conversation where the tool was never called. So the first rejection says only what is wrong.""" for args in ({"lang": "html", "markup": "
too short
"}, {"lang": "jsx", "markup": ""}): first = json.loads(asyncio.run(tools.dispatch("render_preview", args))) assert first["ok"] is False assert "scaffold" not in first, args assert "issues" in first or "error" in first again = json.loads(asyncio.run(tools.dispatch( "render_preview", {**args, "_attempt": 1}))) assert again["ok"] is False assert "scaffold" in again, args def test_repeat_reject_hands_back_the_language_that_was_asked_for(): """Answering a rejected component with a full HTML document tells the model to write the wrong thing entirely.""" jsx = json.loads(asyncio.run(tools.dispatch("render_preview", { "lang": "jsx", "markup": "
too short
", "_attempt": 1, }))) assert "export default function App" in jsx["scaffold"] assert "" not in jsx["scaffold"] html = json.loads(asyncio.run(tools.dispatch("render_preview", { "lang": "html", "markup": "
too short
", "_attempt": 1, }))) assert "" in html["scaffold"] empty = json.loads(asyncio.run(tools.dispatch( "render_preview", {"lang": "tsx", "markup": "", "_attempt": 1}))) assert "export default function App" in empty["scaffold"] def test_asking_for_a_preview_language_or_pointer_interaction_offers_the_tool(): """Each of these is a real prompt from a transcript where the render window should have been reachable. The first one was not: no hint matched 'mouse-over sensitive ... jsx', so the tool was never advertised and the model answered about Euler's formula instead.""" for prompt in ( "Create a mouse-over sensitive Euler fluid field as a jsx or tsx", "write me a small tsx component", "make the particles react to hover", "a real-time simulation I can drag", ): assert tools.wants_render_preview(prompt), prompt # Still narrow: ordinary chat must not pay for a tool turn. for prompt in ( "what's the weather vibe today", "summarise this email thread", "write a concise paragraph about caching", ): assert not tools.wants_render_preview(prompt), prompt assert tools.wants_render_preview("compare these graphs") def test_external_preview_resources_are_rejected_in_attributes_and_css(): for markup in ( 'chart', '', '', ): issues = tools._critique_shared(markup) assert any("external http(s)" in issue for issue in issues), markup def test_every_preview_language_hints_for_itself(): for lang in tools.PREVIEW_LANGS: assert lang in tools._RENDER_HINTS, lang def test_size_guidance_never_quotes_the_minimum(): """Weak models copy the first dimensions they read. Three transcripts produced exactly 320x200 — the old minimum — including one that had a 480x280 example in front of it. Only the wanted size may be spoken.""" schema, _ = tools.REGISTRY["render_preview"] surfaces = [json.dumps(schema)] for lang, markup in (("html", '' + "x" * 60), ("svg", '' + "x" * 60)): surfaces.append(json.dumps(asyncio.run( tools._render_preview(lang=lang, markup=markup, purpose="a chart")))) blob = " ".join(surfaces) assert str(tools._MIN_CANVAS_W) not in blob, "the minimum leaked into guidance" assert str(tools._STAGE_W) in blob def test_prose_only_component_is_rejected_like_a_prose_page(): """The JSX that started the Euler misunderstanding: a component returning three paragraphs. It was accepted because the prose check lived only on the html side.""" prose = """export default function App() { return (

Euler's Formula

The sum of the first n natural numbers is:

{`f(x) = ${sumOfCubes(10)}`}

For example, the sum of the cubes of the first 10 is: {sumOfCubes(10)}

); }""" out = json.loads(asyncio.run(tools.dispatch("render_preview", { "lang": "jsx", "markup": prose, "purpose": "Euler fluid field", }))) assert out["ok"] is False assert any("not a visualization" in i for i in out["issues"]) def test_a_path_parked_in_defs_is_not_a_plot(): """Straight from a transcript: a long inside — never drawn — passed as proof of a real chart while the preview rendered an empty box.""" undrawn = ( '' '' '' ) assert tools._decorative_svg_not_plot(undrawn) out = json.loads(asyncio.run(tools.dispatch("render_preview", { "lang": "svg", "markup": undrawn, "purpose": "a plot of the field", }))) assert out["ok"] is False # The same path where it actually renders is still a plot. drawn = undrawn.replace("", "").replace("", "") assert not tools._decorative_svg_not_plot(drawn) def test_render_nudge_never_reaches_the_streaming_turn(): """The nudge is a synthetic user turn. Left in place it becomes the last thing the user appears to have said, and the model answers it — which is exactly what shipped: "please provide the user's request for the rendering", twice, in place of a bouncing particle system.""" from synapse.chat import _strip_internal_turns, _render_nudge_text real = {"role": "user", "content": "draw me a bouncing particle system"} kept = _strip_internal_turns([ real, {"role": "assistant", "content": "", "tool_calls": [{"function": {"name": "x"}}]}, {"role": "tool", "content": "{}"}, {"role": "user", "content": _render_nudge_text()}, ]) assert kept[-1] == real assert len(kept) == 2 assert kept[0]["role"] == "user" assert "Tool results" in kept[0]["content"] assert "{}" in kept[0]["content"] 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_nudge_says_only_what_to_do_next(): """It cannot refer to something the model can't see, offer a way out, or name a size or language that isn't the one we want — it gets answered literally.""" from synapse.chat import _render_nudge_text nudge = _render_nudge_text().lower() assert "this user request" not in nudge # dangling reference -> "please provide it" assert "clarif" not in nudge # escape hatch -> it gets taken assert str(tools._MIN_CANVAS_W) not in nudge assert f"{tools._STAGE_W}x{tools._STAGE_H}" in nudge for lang in tools.PREVIEW_LANGS: # not a hardcoded "html or svg" assert lang in nudge, lang def test_every_language_offers_a_scaffold(): for lang in tools.PREVIEW_LANGS: assert tools._scaffold_for(lang), f"{lang} has no scaffold" def test_render_preview_rejects_jsx_with_no_component(): out = json.loads(asyncio.run(tools.dispatch("render_preview", { "lang": "jsx", "markup": "const x = 1;\nconsole.log(x);\n// nothing to mount", }))) assert out["ok"] is False assert any("No component to mount" in i for i in out["issues"]) def test_render_preview_rejects_jsx_importing_a_third_party_module(): src = """import { motion } from "framer-motion"; export default function App() { return hello there friend; }""" out = json.loads(asyncio.run(tools.dispatch("render_preview", { "lang": "jsx", "markup": src, }))) assert out["ok"] is False assert any("framer-motion" in i for i in out["issues"]) def test_render_preview_allows_react_imports_in_jsx(): src = """import { useState } from "react"; export default function App() { const [n] = useState(0); return

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_rejects_tiny_decorative_tile(): stub = """
""" out = json.loads(asyncio.run(tools.dispatch("render_preview", { "lang": "html", "markup": stub, }))) assert out["ok"] is False def test_render_preview_rejects_prose_page(): bad = """

Some Topic

This explains an idea in several paragraphs without drawing anything.

  • one
  • two
  • three
  • four

To view a live Preview/Code toggle, use:

more prose

""" out = json.loads(asyncio.run(tools.dispatch("render_preview", { "lang": "html", "title": "Topic", "purpose": "interactive demo", "markup": bad, }))) assert out["ok"] is False assert "scaffold" not in out # withheld on a first rejection assert out.get("repaired") is not True def test_last_ok_render_fence_prefers_tool_result(): from synapse.chat import _last_ok_render_fence fence, meta = _last_ok_render_fence([ {"role": "tool", "content": json.dumps({ "ok": True, "fence": "```html\n\n```", })}, ]) assert fence.startswith("```html") assert meta.get("ok") is True 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_nudges_render_preview_on_visual_ask(): """First turn skips tools; nudge forces a second turn that calls render_preview.""" 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": ( '' '' 'hi' ), }, } }], } 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 any(s == "__status__render_preview" for s in statuses) assert any( m.get("role") == "user" and "render_preview tool now" in (m.get("content") or "") for m in messages ) 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