"""App agent: driving an OpenSwarm-built app through its window.OPENSWARM_APP bridge. Pins the wiring that makes an app drivable without touching a real webview or LLM: - the three App bridge tools translate to a single BrowserEvaluate against the app's bridge, - run_browser_agents forwards app_mode + the "app:" target without creating or navigating a browser card, - the AppAgent delegation tool builds the right task and gates on the user's selected apps, - the orchestrator's selected-app context advertises AppAgent. """ import asyncio import json from backend.apps.agents.browser import browser_agent as BA # --- bridge expression ------------------------------------------------------- def test_app_bridge_expression_describe_and_state(): desc = BA.app_bridge_expression("AppDescribe", {}) assert "window.OPENSWARM_APP.describe()" in desc assert "JSON.stringify" in desc # round-trips as text state = BA.app_bridge_expression("AppGetState", {}) assert "window.OPENSWARM_APP.getState()" in state def test_app_bridge_expression_invoke_serializes_args(): expr = BA.app_bridge_expression("AppInvoke", {"name": "addExpr", "args": {"latex": "y=x^2"}}) # name + args are JSON-encoded into the call assert 'window.OPENSWARM_APP.invoke("addExpr", {"latex": "y=x^2"})' in expr # missing bridge is handled inside the expression, never throws assert "typeof A.describe!=='function'" in expr def test_app_bridge_expression_invoke_defaults_args_to_empty_object(): expr = BA.app_bridge_expression("AppInvoke", {"name": "clear"}) assert 'window.OPENSWARM_APP.invoke("clear", {})' in expr # --- execute_browser_tool routing ------------------------------------------- def test_execute_browser_tool_app_bridge_routes_to_evaluate(monkeypatch): captured = {} async def p_send(request_id, action, browser_id, params, tab_id=""): captured.update(action=action, browser_id=browser_id, params=params) return {"text": json.dumps([{"name": "addExpr"}])} monkeypatch.setattr(BA.ws_manager, "send_browser_command", p_send, raising=False) out = asyncio.run(BA.execute_browser_tool( "AppInvoke", {"name": "addExpr", "args": {"latex": "y=x^2"}}, "app:abc", )) assert captured["action"] == "evaluate" assert captured["browser_id"] == "app:abc" assert "window.OPENSWARM_APP.invoke" in captured["params"]["expression"] assert out["text"] # result passes straight through # --- run_browser_agents app_mode wiring ------------------------------------- def test_run_browser_agents_app_mode_forwards_flag_no_card(monkeypatch): recorded = {} async def p_fake_run_browser_agent(**kwargs): recorded.update(kwargs) return {"summary": "done", "action_log": [], "final_screenshot": None} async def p_boom(*a, **k): raise AssertionError("app mode must not create a browser card") monkeypatch.setattr(BA, "run_browser_agent", p_fake_run_browser_agent, raising=True) monkeypatch.setattr(BA, "p_create_browser_card", p_boom, raising=True) # a connected dashboard so dispatch isn't refused monkeypatch.setattr(BA.ws_manager, "global_connections", [object()], raising=False) results = asyncio.run(BA.run_browser_agents( tasks=[{"task": "graph y=x^2", "browser_id": "app:abc", "app_mode": True}], model="sonnet", dashboard_id="dash-1", )) assert results and results[0]["summary"] == "done" assert recorded["app_mode"] is True assert recorded["browser_id"] == "app:abc" assert recorded["initial_url"] is None # app already loaded; never navigate # --- AppAgent delegation tool ----------------------------------------------- def p_load_mcp_server(monkeypatch, selected): import backend.apps.agents.browser_agent_mcp_server as srv monkeypatch.setattr(srv, "SELECTED_APP_IDS", list(selected), raising=False) return srv def test_app_agent_tool_builds_app_task(monkeypatch): srv = p_load_mcp_server(monkeypatch, ["abc"]) captured = {} def p_call_backend(tasks): captured["tasks"] = tasks return {"results": [{"summary": "graphed it", "action_log": []}]} monkeypatch.setattr(srv, "call_backend", p_call_backend, raising=True) res = srv.handle_tool_call("AppAgent", {"output_id": "abc", "task": "graph y=x^2"}) assert not res.get("isError") task_def = captured["tasks"][0] assert task_def["browser_id"] == "app:abc" assert task_def["app_mode"] is True assert task_def["task"] == "graph y=x^2" def test_app_agent_tool_rejects_unselected_app(monkeypatch): srv = p_load_mcp_server(monkeypatch, ["abc"]) def p_call_backend(tasks): raise AssertionError("must not dispatch an unselected app") monkeypatch.setattr(srv, "call_backend", p_call_backend, raising=True) res = srv.handle_tool_call("AppAgent", {"output_id": "zzz", "task": "graph"}) assert res.get("isError") assert "not a selected app" in res["content"][0]["text"] def test_app_agent_tool_requires_output_id(monkeypatch): srv = p_load_mcp_server(monkeypatch, []) res = srv.handle_tool_call("AppAgent", {"task": "graph"}) assert res.get("isError") assert "output_id is required" in res["content"][0]["text"] # --- orchestrator context advertises AppAgent -------------------------------- def test_selected_app_context_advertises_app_agent(monkeypatch): import os from backend.apps.agents.manager.prompt import prompt_context as pc import backend.apps.outputs.workspace_io as wio class POut: workspace_id = "ws-1" name = "Grapher" monkeypatch.setattr(wio, "load_output", lambda oid: POut(), raising=True) monkeypatch.setattr(os.path, "isdir", lambda p: True, raising=True) monkeypatch.setattr(os.path, "isfile", lambda p: False, raising=True) ctx = pc.build_selected_app_context(["abc"]) assert ctx is not None assert "App id (for AppAgent): abc" in ctx assert "AppAgent(output_id, task)" in ctx # --- bridge readiness + parsing --------------------------------------------- def test_parse_bridge_result_decodes_json_text(): assert BA.parse_bridge_result({"text": json.dumps([{"name": "x"}])}) == [{"name": "x"}] assert BA.parse_bridge_result({"text": "null"}) is None assert BA.parse_bridge_result({"error": "boom"}) is None assert BA.parse_bridge_result({"text": "not json"}) is None assert BA.parse_bridge_result({}) is None def test_bridge_ready_distinguishes_stub_from_registered(): assert BA.bridge_ready([{"name": "x"}]) is True # legacy array assert BA.bridge_ready({"controls": [], "__rev": 1}) is True assert BA.bridge_ready({"__ready": False, "__rev": 0}) is False # template stub assert BA.bridge_ready({"__error__": "threw"}) is False assert BA.bridge_ready(None) is False def test_render_app_controls_array_and_object_forms(): # Legacy array form: no rules, controls rendered. rules_md, controls_md = BA.render_app_controls([{"name": "clear", "description": "wipe"}]) assert rules_md == "" assert "- `clear`: wipe" in controls_md # New object form: rules + keys + args rendered. rules_md, controls_md = BA.render_app_controls({ "rules": "Flappy Bird. Keep the bird airborne.", "controls": [{"name": "flap", "keys": "Space = flap", "args": {"force": "number"}, "description": "Flap once"}], "__rev": 3, }) assert "Flappy Bird" in rules_md assert "- `flap`" in controls_md assert "[Space = flap]" in controls_md assert '"force"' in controls_md # Not-ready / absent bridge yields nothing to render. assert BA.render_app_controls({"__ready": False}) is None assert BA.render_app_controls(None) is None # --- AppDescribe waits for a still-booting bridge ---------------------------- def test_app_describe_polls_until_bridge_ready(monkeypatch): calls = {"n": 0} ready = {"rules": "r", "controls": [{"name": "x"}], "__rev": 1} async def p_send(request_id, action, browser_id, params, tab_id=""): calls["n"] += 1 if calls["n"] < 3: return {"text": json.dumps({"__ready": False, "__rev": 0})} return {"text": json.dumps(ready)} async def p_no_sleep(p_s): return None monkeypatch.setattr(BA.ws_manager, "send_browser_command", p_send, raising=False) monkeypatch.setattr(BA.asyncio, "sleep", p_no_sleep, raising=True) out = asyncio.run(BA.execute_browser_tool("AppDescribe", {}, "app:abc")) assert calls["n"] == 3 # polled twice, succeeded on the third assert BA.parse_bridge_result(out) == ready def test_app_invoke_does_not_poll(monkeypatch): calls = {"n": 0} async def p_send(request_id, action, browser_id, params, tab_id=""): calls["n"] += 1 return {"text": json.dumps({"__ready": False})} # would loop forever if AppInvoke waited async def p_no_sleep(p_s): return None monkeypatch.setattr(BA.ws_manager, "send_browser_command", p_send, raising=False) monkeypatch.setattr(BA.asyncio, "sleep", p_no_sleep, raising=True) asyncio.run(BA.execute_browser_tool("AppInvoke", {"name": "flap"}, "app:abc")) assert calls["n"] == 1 # single shot, no readiness wait