"""synapse/slash_commands.py (the /tool_name(arg=val) parser) and its wiring into chat_stream_endpoint (direct dispatch, no model call, no approval round-trip) plus the ten curry_* tools it can now reach. """ import json import pytest from fastapi.testclient import TestClient from synapse.slash_commands import SlashCommand, SlashCommandError, parse_slash_command from synapse.main import app from synapse import tools # --------------------------------------------------------------------------- # Parser # --------------------------------------------------------------------------- def test_parses_keyword_arguments_as_python_literals(): result = parse_slash_command('/curry_call_function(name="x", version=1, args={"a": 1})') assert result == SlashCommand( tool="curry_call_function", args={"name": "x", "version": 1, "args": {"a": 1}}, ) def test_parses_no_arguments(): assert parse_slash_command("/curry_list_functions()") == SlashCommand(tool="curry_list_functions", args={}) def test_non_slash_message_returns_none(): assert parse_slash_command("just chatting, not a command") is None def test_slash_without_parens_returns_none(): # The TUI's own local commands (/model foo, /new) use this shape — must # never be mistaken for a tool call. assert parse_slash_command("/model gpt") is None def test_slash_embedded_in_prose_returns_none(): assert parse_slash_command('hey /curry_call_function(name="x", version=1) run this') is None def test_name_or_call_as_argument_value_is_rejected(): # ast.literal_eval only accepts literals — a bare name or a call is a # parse failure, not a value, so nothing here is ever evaluated. result = parse_slash_command("/curry_call_function(x=some_name)") assert isinstance(result, SlashCommandError) result2 = parse_slash_command('/curry_call_function(x=__import__("os"))') assert isinstance(result2, SlashCommandError) def test_positional_arguments_are_rejected(): result = parse_slash_command("/curry_call_function(1, 2)") assert isinstance(result, SlashCommandError) def test_double_star_unpacking_is_rejected(): result = parse_slash_command('/curry_call_function(**{"a": 1})') assert isinstance(result, SlashCommandError) def test_malformed_syntax_is_rejected(): result = parse_slash_command("/curry_call_function(name=)") assert isinstance(result, SlashCommandError) # --------------------------------------------------------------------------- # Curry tool registration # --------------------------------------------------------------------------- _CURRY_ACTION_TOOLS = { "curry_declare_constant", "curry_retire_constant", "curry_declare_function", "curry_retire_function", "curry_call_function", } _CURRY_READ_TOOLS = { "curry_get_constant", "curry_get_constant_latest", "curry_list_constants", "curry_get_function", "curry_list_functions", } def test_all_curry_tools_registered(): for name in _CURRY_ACTION_TOOLS | _CURRY_READ_TOOLS: assert name in tools.REGISTRY def test_curry_write_and_execute_tools_are_gated_actions(): for name in _CURRY_ACTION_TOOLS: assert tools.is_action(name), name assert name in tools.ALWAYS_ASK_ACTION_TOOLS, name def test_curry_read_tools_are_not_actions(): for name in _CURRY_READ_TOOLS: assert not tools.is_action(name), name # --------------------------------------------------------------------------- # End-to-end HTTP: direct dispatch, no model call, no approval round-trip # --------------------------------------------------------------------------- @pytest.fixture def client(): return TestClient(app) def _sse_events(body: str) -> list[tuple[str, str]]: events = [] event_type = "message" for block in body.split("\n\n"): for line in block.splitlines(): if line.startswith("event: "): event_type = line[len("event: "):].strip() elif line.startswith("data: "): events.append((event_type, line[len("data: "):])) event_type = "message" return events def test_slash_command_dispatches_without_model_call(client, monkeypatch): from synapse import chat as chatmod async def _boom(*a, **k): raise AssertionError("the model must not be called for a slash-command") monkeypatch.setattr(chatmod, "stream_chat_response", _boom) resp = client.post("/chat/stream", json={ "message": '/curry_list_functions()', "conversation_id": "test-slash-http-1", }) events = _sse_events(resp.text) assert ("status", json.dumps({"tool": "curry_list_functions"})) in events assert any(t == "done" for t, _ in events) def test_slash_command_skips_approval_round_trip(client, monkeypatch): async def _fake_dispatch(name, args): return json.dumps({"ok": True, "result": "did it"}) monkeypatch.setattr(tools, "dispatch", _fake_dispatch) resp = client.post("/chat/stream", json={ "message": '/curry_call_function(name="x", version=1, args={})', "conversation_id": "test-slash-http-2", }) events = _sse_events(resp.text) assert not any(t == "tool_request" for t, _ in events) assert any(t == "done" for t, _ in events) def test_slash_command_uses_fence_from_result_when_present(client, monkeypatch): async def _fake_dispatch(name, args): return json.dumps({"ok": True, "fence": "```nexus-curry\n{\"kind\": \"x\"}\n```"}) monkeypatch.setattr(tools, "dispatch", _fake_dispatch) resp = client.post("/chat/stream", json={ "message": '/curry_call_function(name="x", version=1, args={})', "conversation_id": "test-slash-http-3", }) events = _sse_events(resp.text) content = [d for t, d in events if t == "message"] assert content and "nexus-curry" in content[0] def test_slash_command_unknown_tool_yields_error_not_a_chat_reply(client): resp = client.post("/chat/stream", json={ "message": "/not_a_real_tool(a=1)", "conversation_id": "test-slash-http-4", }) events = _sse_events(resp.text) assert any(t == "error" for t, _ in events) assert not any(t == "status" for t, _ in events) def test_slash_command_malformed_yields_error(client): resp = client.post("/chat/stream", json={ "message": "/curry_call_function(x=some_name)", "conversation_id": "test-slash-http-5", }) events = _sse_events(resp.text) assert any(t == "error" for t, _ in events) def test_message_with_leading_slash_but_not_command_shaped_goes_to_chat(client, monkeypatch): # e.g. "/model gpt" or plain prose starting with "/" - must still reach # the normal model path, not be swallowed as a broken slash-command. called = {} async def _fake_stream(*a, **k): called["hit"] = True return yield # pragma: no cover - make this an async generator # main.py did `from .chat import stream_chat_response`, a separate name # binding from chat.stream_chat_response - patch the one main.py actually # calls. from synapse import main as mainmod monkeypatch.setattr(mainmod, "stream_chat_response", _fake_stream) client.post("/chat/stream", json={ "message": "/model gpt", "conversation_id": "test-slash-http-6", }) assert called.get("hit") is True