refactor(preview): simplify compile and validation paths
Load Sucrase only when a JSX/TSX preview is opened, remove the hand-written transform, and leave subjective render evaluation to the reader while retaining structural fence validation.
This commit is contained in:
+45
-291
@@ -208,6 +208,8 @@ def test_routed_reference_playbook_contributes_its_tools(tmp_path, monkeypatch):
|
||||
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"]
|
||||
@@ -217,48 +219,18 @@ def test_standing_schemas_include_render_preview():
|
||||
assert not tools.wants_render_preview("what's the weather vibe today")
|
||||
|
||||
|
||||
def test_render_preview_rejects_canvas_that_never_draws():
|
||||
bad = """<!DOCTYPE html><html><body>
|
||||
<canvas id="c" width="480" height="240"></canvas>
|
||||
<script>
|
||||
const canvas = document.getElementById('c');
|
||||
function spin(num) {
|
||||
while (num !== 1) {
|
||||
num = num % 2 === 0 ? num / 2 : 3 * num + 1;
|
||||
canvas.width = canvas.width;
|
||||
}
|
||||
}
|
||||
spin(40);
|
||||
</script></body></html>"""
|
||||
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 = """<!DOCTYPE html><html><head><style>
|
||||
.colla { width: 40px; height: 40px; background-color: #2563eb; }
|
||||
</style></head><body>
|
||||
def test_render_preview_packages_markup_without_grading_its_quality():
|
||||
markup = """<!DOCTYPE html><html><body>
|
||||
<canvas id="c" width="40" height="40"></canvas>
|
||||
<script>
|
||||
const canvas = document.getElementById('c');
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillRect(0, 0, 40, 40);
|
||||
</script></body></html>"""
|
||||
<script>c.width = c.width;</script>
|
||||
</body></html>"""
|
||||
out = json.loads(asyncio.run(tools.dispatch("render_preview", {
|
||||
"lang": "html", "markup": bad, "purpose": "interactive plot demo",
|
||||
"lang": "html", "title": "Demo", "markup": markup,
|
||||
})))
|
||||
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
|
||||
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():
|
||||
@@ -305,8 +277,7 @@ def test_code_that_throws_is_left_to_the_previews_own_error_channel():
|
||||
|
||||
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."""
|
||||
number."""
|
||||
broken_at_runtime = """<!DOCTYPE html><html><body>
|
||||
<canvas id="c" width="480" height="280"></canvas>
|
||||
<script>
|
||||
@@ -341,35 +312,6 @@ function plot() {
|
||||
assert out["ok"] is True, out.get("issues")
|
||||
|
||||
|
||||
def test_render_preview_rejects_decorative_svg_without_guessing_algorithm():
|
||||
bad = """<!DOCTYPE html><html><body>
|
||||
<div class="wrap">
|
||||
<input id="n" type="number" value="27"><button id="go">Plot</button>
|
||||
</div>
|
||||
<svg width="480" height="200" viewBox="0 0 480 200">
|
||||
<defs><linearGradient id="g"><stop offset="0%" stop-color="#1a1a1a"/></linearGradient></defs>
|
||||
<rect x="0" y="0" width="480" height="200" fill="url(#g)"/>
|
||||
<line x1="0" y1="100" x2="480" y2="100" stroke="#fff"/>
|
||||
</svg>
|
||||
<script>
|
||||
document.getElementById('go').onclick = function() {
|
||||
var line = document.createElementNS('line');
|
||||
document.querySelector('.wrap').appendChild(line);
|
||||
};
|
||||
</script></body></html>"""
|
||||
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")
|
||||
|
||||
@@ -432,72 +374,16 @@ def test_render_preview_accepts_a_jsx_component():
|
||||
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."""
|
||||
def test_render_preview_does_not_grade_jsx_against_its_purpose():
|
||||
component = """export default function Form() {
|
||||
const [name, setName] = useState("");
|
||||
return <label>Name <input value={name} onInput={(e) => setName(e.target.value)} /></label>;
|
||||
}"""
|
||||
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", {
|
||||
out = 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 = """<!doctype html><html><body>
|
||||
<label>Value <input id="value" type="number" value="2"></label>
|
||||
<button onclick="result.textContent = +value.value * 2">Double</button>
|
||||
<output id="result">4</output>
|
||||
</body></html>"""
|
||||
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": "<div>too short</div>"},
|
||||
{"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": "<div>too short</div>", "_attempt": 1,
|
||||
})))
|
||||
assert "export default function App" in jsx["scaffold"]
|
||||
assert "<!DOCTYPE html>" not in jsx["scaffold"]
|
||||
|
||||
html = json.loads(asyncio.run(tools.dispatch("render_preview", {
|
||||
"lang": "html", "markup": "<div>too short</div>", "_attempt": 1,
|
||||
})))
|
||||
assert "<!DOCTYPE html>" 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"]
|
||||
assert out["ok"] is True
|
||||
assert "issues" not in out
|
||||
|
||||
|
||||
def test_asking_for_a_preview_language_or_pointer_interaction_offers_the_tool():
|
||||
@@ -524,14 +410,14 @@ def test_asking_for_a_preview_language_or_pointer_interaction_offers_the_tool():
|
||||
assert tools.wants_render_preview("compare these graphs")
|
||||
|
||||
|
||||
def test_external_preview_resources_are_rejected_in_attributes_and_css():
|
||||
for markup in (
|
||||
'<img src=https://example.com/chart.png alt="chart">',
|
||||
'<style>.chart { background: url("https://example.com/chart.png"); }</style>',
|
||||
'<style>@import "https://example.com/chart.css";</style>',
|
||||
):
|
||||
issues = tools._critique_shared(markup)
|
||||
assert any("external http(s)" in issue for issue in issues), markup
|
||||
def test_external_preview_resources_are_packaged_for_the_csp_to_block():
|
||||
markup = '<img src="https://example.com/chart.png" alt="chart">'
|
||||
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():
|
||||
@@ -539,81 +425,6 @@ def test_every_preview_language_hints_for_itself():
|
||||
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", '<canvas width="40" height="40"></canvas>' + "x" * 60),
|
||||
("svg", '<svg width="40" height="40"><rect/></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 (
|
||||
<div>
|
||||
<h1>Euler's Formula</h1>
|
||||
<p>The sum of the first n natural numbers is:</p>
|
||||
<p>{`f(x) = ${sumOfCubes(10)}`}</p>
|
||||
<p>For example, the sum of the cubes of the first 10 is: {sumOfCubes(10)}</p>
|
||||
</div>
|
||||
);
|
||||
}"""
|
||||
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 <path> inside <defs> — never drawn —
|
||||
passed as proof of a real chart while the preview rendered an empty box."""
|
||||
undrawn = (
|
||||
'<svg width="480" height="280" xmlns="http://www.w3.org/2000/svg"><defs>'
|
||||
'<path d="M10,20L 10,190L 20,180L 30,170L 40,160L 50,150L 60,140L 70,130L 200L 0L" />'
|
||||
'</defs><rect x="0" y="0" width="480" height="280" fill="none" stroke="#000" /></svg>'
|
||||
)
|
||||
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("<defs>", "").replace("</defs>", "")
|
||||
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
|
||||
@@ -630,43 +441,19 @@ def test_normal_tool_results_reach_streaming_turn():
|
||||
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 <motion.div>hello there friend</motion.div>;
|
||||
}"""
|
||||
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_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 () => <motion.div />;',
|
||||
"export default () => <div style={{width: 40}}>tiny</div>;",
|
||||
)
|
||||
for source in sources:
|
||||
out = json.loads(asyncio.run(tools.dispatch("render_preview", {
|
||||
"lang": "jsx", "markup": source,
|
||||
})))
|
||||
assert out["ok"] is True
|
||||
assert source in out["fence"]
|
||||
assert "issues" not in out
|
||||
|
||||
|
||||
def test_render_preview_allows_react_imports_in_jsx():
|
||||
@@ -681,43 +468,13 @@ export default function App() {
|
||||
assert out["ok"] is True, out.get("issues")
|
||||
|
||||
|
||||
def test_render_preview_rejects_tiny_decorative_tile():
|
||||
stub = """<div style="width:40px;height:40px;background:#2563eb;border:1px solid #000"></div>"""
|
||||
def test_render_preview_still_rejects_missing_markup():
|
||||
out = json.loads(asyncio.run(tools.dispatch("render_preview", {
|
||||
"lang": "html", "markup": stub,
|
||||
"lang": "tsx", "markup": "",
|
||||
})))
|
||||
assert out["ok"] is False
|
||||
|
||||
|
||||
def test_render_preview_rejects_prose_page():
|
||||
bad = """<!DOCTYPE html><html><body>
|
||||
<div><h1>Some Topic</h1>
|
||||
<p>This explains an idea in several paragraphs without drawing anything.</p>
|
||||
<ul><li>one</li><li>two</li><li>three</li><li>four</li></ul>
|
||||
<p>To view a live Preview/Code toggle, use:</p>
|
||||
<pre><html><body><p>more prose</p></body></html></pre>
|
||||
</div></body></html>"""
|
||||
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<canvas width=\"480\" height=\"280\"></canvas>\n```",
|
||||
})},
|
||||
])
|
||||
assert fence.startswith("```html")
|
||||
assert meta.get("ok") is True
|
||||
assert "markup is required" in out["error"]
|
||||
assert "scaffold" not in out
|
||||
|
||||
|
||||
def test_coerce_tool_calls_from_content_json():
|
||||
@@ -788,8 +545,7 @@ def test_tool_loop_runs_content_json_tool_call(monkeypatch):
|
||||
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."""
|
||||
def test_tool_loop_does_not_inject_a_render_preview_nudge():
|
||||
class _SkipThenCall:
|
||||
def __init__(self):
|
||||
self.n = 0
|
||||
@@ -821,11 +577,9 @@ def test_tool_loop_nudges_render_preview_on_visual_ask():
|
||||
_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
|
||||
)
|
||||
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"):
|
||||
|
||||
Reference in New Issue
Block a user