From a35bfaaf7dd7f86dee5d0421ef2e378cc2a22189 Mon Sep 17 00:00:00 2001 From: SirKentut <81878031+SirKentut@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:06:10 -0700 Subject: [PATCH] [pierre] feat/app-agent: drive web apps through the OPENSWARM_APP bridge Browser agent gains app-control logic + schema, app-builder skill doc, run.sh agent-bridge presence check, and app-agent tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/apps/agents/browser/browser_agent.py | 193 +++++++++++++++++- backend/apps/agents/browser/browser_schema.py | 45 ++-- backend/apps/outputs/app_builder_skill.md | 68 +++--- .../outputs/webapp_template/frontend/run.sh | 26 +++ backend/apps/outputs/workspace_io.py | 1 + backend/tests/test_app_agent.py | 95 +++++++++ electron/package-lock.json | 8 +- 7 files changed, 378 insertions(+), 58 deletions(-) diff --git a/backend/apps/agents/browser/browser_agent.py b/backend/apps/agents/browser/browser_agent.py index 21851de3..93c043eb 100644 --- a/backend/apps/agents/browser/browser_agent.py +++ b/backend/apps/agents/browser/browser_agent.py @@ -9,6 +9,7 @@ Sub-agents appear as visible AgentSession cards on the dashboard. import asyncio import json import logging +import os import re import time from datetime import datetime @@ -114,6 +115,110 @@ def _app_bridge_expression(tool_name: str, tool_input: dict) -> str: ) +# App-bridge readiness. The template ships window.OPENSWARM_APP from first paint +# but in a "not ready" state until the app calls register(...). On the agent's +# first turn the app may still be mounting (Vite cold-boot is 10-30s), so the +# reads poll briefly for the bridge to come up instead of declaring it absent. +_BRIDGE_READY_WAIT_MS = 8000 +_BRIDGE_POLL_INTERVAL_MS = 400 + + +def _parse_bridge_result(result: dict) -> object: + """Decode the JSON string an app-bridge evaluate returns (it always returns + JSON text and never throws). Returns the decoded value, or None when it is + undecodable or errored at the transport level.""" + if not isinstance(result, dict) or "error" in result: + return None + raw = result.get("text") + if raw is None: + return None + try: + return json.loads(raw) + except (TypeError, ValueError): + return None + + +def _bridge_ready(value: object) -> bool: + """True when a decoded describe()/getState() value means a registered bridge. + Legacy apps return a plain array (ready); the template stub returns + {'__ready': False} until register() runs; None means no bridge present yet.""" + if isinstance(value, list): + return True + if isinstance(value, dict): + return value.get("__ready") is not False and "__error__" not in value + return value is not None + + +def _app_output_id(browser_id: str) -> str | None: + return browser_id[4:] if browser_id.startswith("app:") else None + + +def _app_workspace_dir(browser_id: str) -> str | None: + """Resolve the on-disk workspace folder for an `app:` target.""" + oid = _app_output_id(browser_id) + if not oid: + return None + try: + from backend.apps.outputs.workspace_io import load_output + from backend.config.paths import OUTPUTS_WORKSPACE_DIR + out = load_output(oid) + if not out or not getattr(out, "workspace_id", None): + return None + return os.path.join(OUTPUTS_WORKSPACE_DIR, out.workspace_id) + except Exception: + return None + + +def _render_app_controls(describe_value: object) -> tuple[str, str] | None: + """From a decoded describe() value build (rules_md, controls_md). Returns + None when the value is not a ready, usable describe.""" + if isinstance(describe_value, list): + rules, controls = "", describe_value + elif _bridge_ready(describe_value) and isinstance(describe_value, dict): + rules = str(describe_value.get("rules") or "") + controls = describe_value.get("controls") or [] + else: + return None + lines = ["# Controls", ""] + for c in controls: + if not isinstance(c, dict): + continue + row = f"- `{c.get('name', '')}`" + if c.get("args"): + row += f" args={json.dumps(c['args'])}" + if c.get("keys"): + row += f" [{c['keys']}]" + if c.get("description"): + row += f": {c['description']}" + lines.append(row) + controls_md = "\n".join(lines) + "\n" + rules_md = (rules.strip() + "\n") if rules.strip() else "" + return rules_md, controls_md + + +def _persist_app_controls(browser_id: str, describe_value: object) -> None: + """Cache rules.md + controls.md into the app workspace so the agent reads them + up front and only re-describes when controls change. Best-effort; written + under .openswarm/ so they stay out of the app's own file tree.""" + rendered = _render_app_controls(describe_value) + if not rendered: + return + folder = _app_workspace_dir(browser_id) + if not folder or not os.path.isdir(folder): + return + rules_md, controls_md = rendered + try: + cache_dir = os.path.join(folder, ".openswarm") + os.makedirs(cache_dir, exist_ok=True) + with open(os.path.join(cache_dir, "controls.md"), "w", encoding="utf-8") as f: + f.write(controls_md) + if rules_md: + with open(os.path.join(cache_dir, "rules.md"), "w", encoding="utf-8") as f: + f.write(rules_md) + except Exception: + logger.debug("[app-agent] failed to persist controls cache", exc_info=True) + + async def execute_browser_tool( tool_name: str, tool_input: dict, browser_id: str, tab_id: str = "", ) -> dict: @@ -128,14 +233,28 @@ async def execute_browser_tool( action = "evaluate" expr = _app_bridge_expression(tool_name, tool_input) params = {"expression": expr} - request_id = uuid4().hex - if _trace: - logger.info(f"[app-agent] DISPATCH {tool_name} -> {browser_id} (req {request_id[:8]}) js={expr[:120]}") - result = await ws_manager.send_browser_command( - request_id, action, browser_id, params, tab_id=tab_id, - ) - if _trace: - logger.info(f"[app-agent] RESULT {tool_name} <- {browser_id}: {json.dumps(result)[:300]}") + + async def _eval_once() -> dict: + rid = uuid4().hex + if _trace: + logger.info(f"[app-agent] DISPATCH {tool_name} -> {browser_id} (req {rid[:8]}) js={expr[:120]}") + r = await ws_manager.send_browser_command(rid, action, browser_id, params, tab_id=tab_id) + if _trace: + logger.info(f"[app-agent] RESULT {tool_name} <- {browser_id}: {json.dumps(r)[:300]}") + return r + + result = await _eval_once() + # Reads poll for the bridge to come up (app still mounting on turn 1). + # AppInvoke does not wait: its action either exists right now or it does + # not, and a missing action should surface immediately. + if tool_name in ("AppDescribe", "AppGetState"): + waited = 0 + while waited < _BRIDGE_READY_WAIT_MS and not _bridge_ready(_parse_bridge_result(result)): + await asyncio.sleep(_BRIDGE_POLL_INTERVAL_MS / 1000) + waited += _BRIDGE_POLL_INTERVAL_MS + result = await _eval_once() + if tool_name == "AppDescribe": + _persist_app_controls(browser_id, _parse_bridge_result(result)) return result action = ACTION_MAP.get(tool_name) @@ -572,10 +691,66 @@ async def run_browser_agent( ) clear_browser_history(browser_id) prior_messages = [] + # App mode: read the bridge's rules + controls ONCE up front and front-load + # them, so the agent knows the app's purpose and every control before its + # first action (no screenshot fumbling) and need not call AppDescribe again + # until controls change. This is also the runtime bridge gate: if the bridge + # never comes up, fail loudly into the logs + the agent's first message (and, + # under OPENSWARM_REQUIRE_BRIDGE=1, end the run rather than UI-fumble). + app_front_load = "" + if app_mode and not prior_messages: + try: + _dv = _parse_bridge_result(await execute_browser_tool("AppDescribe", {}, browser_id, tab_id)) + except Exception: + _dv = None + logger.debug("[app-agent] startup AppDescribe failed", exc_info=True) + _rendered = _render_app_controls(_dv) + if _rendered: + _rules_md, _controls_md = _rendered + _rev = _dv.get("__rev") if isinstance(_dv, dict) else None + _block = [ + "\n\n[The app's bridge is live; its rules and controls were read " + "for you. Act directly; do NOT call AppDescribe again unless " + "AppGetState reports a changed __rev.]" + ] + if _rules_md.strip(): + _block.append("App rules / objective:\n" + _rules_md.strip()) + _block.append(_controls_md.strip()) + if _rev is not None: + _block.append(f"(controls __rev: {_rev})") + app_front_load = "\n\n".join(_block) + else: + _oid = _app_output_id(browser_id) or browser_id + _msg = ( + f"BRIDGE MISSING: window.OPENSWARM_APP not registered - " + f"app '{_oid}' is not agent-operable" + ) + logger.error(f"[app-agent] {_msg}") + if os.environ.get("OPENSWARM_REQUIRE_BRIDGE") == "1": + session.status = "completed" + agent_manager._sync_session_close(session) + await ws_manager.send_to_session(session_id, "agent:status", { + "session_id": session_id, "status": "completed", + "session": session.model_dump(mode="json"), + }) + return { + "session_id": session_id, "browser_id": browser_id, + "summary": f"This app is not agent-operable: {_msg}.", + "done": True, "success": False, + "action_log": [], "final_screenshot": None, + } + app_front_load = ( + f"\n\n[{_msg}. AppDescribe/AppInvoke will not work. Fall back to " + "driving the UI directly (BrowserListInteractives, " + "BrowserClickIndex, BrowserBatch, BrowserScreenshot). If you " + "cannot operate it, say so in Done with success=false.]" + ) + # Front-load the prefetched perception into the first user turn so the model # can act immediately (only when this is a fresh conversation; a resumed one # already knows the page). The visible task text stays clean. - first_user_content = task + preloaded_perception if (preloaded_perception and not prior_messages) else task + _front = preloaded_perception or app_front_load + first_user_content = task + _front if (_front and not prior_messages) else task messages: list[dict] = list(prior_messages) + [{"role": "user", "content": first_user_content}] # Seed with the front-loaded reads: they really ran and returned content, so a # read task the agent answers straight from them is NOT a "did nothing" ghost. diff --git a/backend/apps/agents/browser/browser_schema.py b/backend/apps/agents/browser/browser_schema.py index 546d127b..fee0fca5 100644 --- a/backend/apps/agents/browser/browser_schema.py +++ b/backend/apps/agents/browser/browser_schema.py @@ -678,13 +678,16 @@ APP_TOOLS_SCHEMA = [ { "name": "AppDescribe", "description": ( - "Read the app's CURRENT list of actions you can take, straight from the " - "app itself (window.OPENSWARM_APP.describe()). Returns an array of " - "{name, args, description}. The app's controls are DYNAMIC, they appear " - "and disappear as state changes, so call this again after any AppInvoke " - "that could add or remove actions; never assume the list is stable. " - "Returns null if the app does not expose the bridge (then fall back to " - "BrowserListInteractives/BrowserScreenshot)." + "Read the app's rules and CURRENT list of actions, straight from the " + "app itself (window.OPENSWARM_APP.describe()). Returns " + "{rules, controls, __rev}: rules is what the app is and its objective, " + "controls is an array of {name, args, description, keys}, and __rev is " + "a revision number. (Older apps may return a bare array of controls.) " + "These are ALREADY front-loaded into your first message, so you rarely " + "need to call this. The app's controls are DYNAMIC: call this again " + "ONLY when AppGetState reports a changed __rev (e.g. after an AppInvoke " + "added or removed actions). Returns null if the app exposes no bridge " + "(then fall back to BrowserListInteractives/BrowserScreenshot)." ), "input_schema": {"type": "object", "properties": {}, "required": []}, }, @@ -693,7 +696,10 @@ APP_TOOLS_SCHEMA = [ "description": ( "Read a small JSON snapshot of the app's current state " "(window.OPENSWARM_APP.getState()). Use it to check what's on screen " - "and to verify an action landed. Returns null if the bridge is absent." + "and to verify an action landed. The snapshot includes __rev, the " + "controls revision: if it differs from the __rev you were given, the " + "controls changed, so call AppDescribe to refresh them. Returns null " + "if the bridge is absent." ), "input_schema": {"type": "object", "properties": {}, "required": []}, }, @@ -978,22 +984,25 @@ APP_SYSTEM_PROMPT = ( "## How you see and act: the app's own bridge (this is the fast path)\n" "The app exposes a native bridge, window.OPENSWARM_APP, with three calls you " "reach through tools:\n" - "- AppDescribe -> the CURRENT list of actions {name, args, description}.\n" - "- AppGetState -> a small JSON snapshot of what's on screen.\n" + "- AppDescribe -> {rules, controls, __rev}: the app's objective and its " + "current actions {name, args, description, keys}.\n" + "- AppGetState -> a small JSON snapshot of what's on screen (includes __rev).\n" "- AppInvoke(name, args) -> perform one action.\n" - "Always start with AppDescribe to learn the real action names and arg shapes, " - "then AppInvoke them. This reads the app's true structure directly, so you do " - "NOT need screenshots, the DOM, or the accessibility tree.\n" + "The app's rules and controls have ALREADY been read for you and placed in " + "your first message, so you can start invoking actions immediately; you do " + "NOT need screenshots, the DOM, or the accessibility tree, and you usually do " + "NOT need to call AppDescribe at all.\n" "Only ever call actions that AppDescribe actually returned. You operate the app, " "you do NOT change it: never invent action names, and never try to add, remove, " "or redefine the app's available actions or edit its code. If what the user wants " "isn't reachable through the exposed actions, say so in Done.\n\n" - "## Controls are DYNAMIC\n" - "Actions and state change as you interact (the app adds and removes controls). " - "Never cache the action list: after any AppInvoke that could change what's " - "available, call AppDescribe again before relying on it. Verify outcomes with " - "AppGetState rather than assuming.\n\n" + "## Controls are DYNAMIC, but you only re-read on a __rev change\n" + "Actions can change as you interact (the app adds and removes controls). The " + "front-loaded controls came with a __rev number. Use AppGetState to verify " + "outcomes; if its __rev differs from the one you have, the controls changed, " + "so call AppDescribe ONCE to refresh them. As long as __rev is unchanged, " + "trust the controls you already have and do not re-describe.\n\n" "## If there is no bridge\n" "If AppDescribe (or AppGetState) returns null, this app doesn't expose the " diff --git a/backend/apps/outputs/app_builder_skill.md b/backend/apps/outputs/app_builder_skill.md index 4fb08c9a..8c3b86bf 100644 --- a/backend/apps/outputs/app_builder_skill.md +++ b/backend/apps/outputs/app_builder_skill.md @@ -325,58 +325,76 @@ export const JOBS_LIST = '/api/jobs/list'; --- -## Make the app agent-operable — the `OPENSWARM_APP` bridge +## Make the app agent-operable: the `OPENSWARM_APP` bridge An agent can drive this app on the user's behalf (e.g. "graph y=x^2 on my -Desmos app"). It does NOT do that by clicking pixels or scraping the DOM — -that's slow and an app's DOM is often a bare ``. Instead, expose a -tiny bridge on `window` and the agent reads/acts through it in one fast -`executeJavaScript` call. **Always add this bridge to every app you build.** +Desmos app"). It does NOT do that by clicking pixels or scraping the DOM (slow, +and an app's DOM is often a bare ``). Instead it reads and acts through +`window.OPENSWARM_APP`, a bridge the template already ships for you +(`src/agentBridge.ts`, installed before your app mounts). -Set `window.OPENSWARM_APP` with three functions: +**You do not wire up the bridge; you `register()` into it.** Call +`window.OPENSWARM_APP.register({ rules, controls, getState, invoke })` once your +app's core object exists (e.g. in a mount `useEffect`). This is REQUIRED for +every app: the runtime verifies it and the agent's first action fails loudly +with `BRIDGE MISSING` if you forget. Pass: -- `describe()` → the **current** list of actions, `[{ name, args?, description? }]`. - Recompute it live on every call; controls are dynamic, so return only what's - actually available right now. -- `getState()` → a **small** JSON snapshot of the app's relevant state (used to - verify an action landed). Keep it compact — this is the latency budget. -- `invoke(name, args)` → perform the named action and return a result (or throw +- `rules` (string) - what the app is and its objective, in plain prose. This is + what the agent reads to understand the app (e.g. "Flappy Bird. Keep the bird + airborne through the pipe gaps; the game ends on a collision."). +- `controls` - an array of `{ name, args?, description?, keys? }`, OR a function + returning that array when controls are dynamic. `keys` is an optional + human-style hint (e.g. `"Space = flap"`). Return only what's available now. +- `getState()` - a **small** JSON snapshot used to verify an action landed. Keep + it compact; this is the latency budget. +- `invoke(name, args)` - perform the named action and return a result (or throw a string the agent will read). Keep `args` shapes simple (strings, numbers, booleans, small objects). The agent -only ever calls actions that `describe()` returned; it never edits your code. +only ever calls actions that `controls` listed; it never edits your code. When +dynamic controls change, call `window.OPENSWARM_APP.refresh()` so the agent +knows to re-read them (it bumps the `__rev` the agent watches). ```tsx -// Register once the app's core object exists (e.g. after the calculator mounts). // `calc` here is the app's own API (Desmos example); use whatever yours exposes. function registerAgentBridge(calc: any) { - (window as any).OPENSWARM_APP = { - describe() { - const actions = [ + window.OPENSWARM_APP!.register({ + rules: 'A graphing calculator. Plot and remove expressions like y=x^2 or y=sin(x).', + controls() { + const controls = [ { name: 'addExpr', args: { latex: 'string' }, description: 'Add a graph expression, e.g. y=x^2' }, { name: 'clear', description: 'Remove all expressions' }, ]; // Dynamic: only offer removeExpr when something is on the graph. if (calc.getExpressions().length > 0) { - actions.push({ name: 'removeExpr', args: { id: 'string' }, description: 'Remove one expression by id' }); + controls.push({ name: 'removeExpr', args: { id: 'string' }, description: 'Remove one expression by id' }); } - return actions; + return controls; }, getState() { return { expressions: calc.getExpressions().map((e: any) => ({ id: e.id, latex: e.latex })) }; }, invoke(name: string, args: any = {}) { - if (name === 'addExpr') { const id = String(Date.now()); calc.setExpression({ id, latex: args.latex }); return { id }; } - if (name === 'removeExpr') { calc.removeExpression({ id: args.id }); return { ok: true }; } - if (name === 'clear') { calc.setBlank(); return { ok: true }; } + if (name === 'addExpr') { const id = String(Date.now()); calc.setExpression({ id, latex: args.latex }); window.OPENSWARM_APP!.refresh(); return { id }; } + if (name === 'removeExpr') { calc.removeExpression({ id: args.id }); window.OPENSWARM_APP!.refresh(); return { ok: true }; } + if (name === 'clear') { calc.setBlank(); window.OPENSWARM_APP!.refresh(); return { ok: true }; } throw `Unknown action: ${name}`; }, - }; + }); } ``` -If you skip the bridge the agent falls back to slow UI-driving, so apps meant to -be agent-operated should always register it. +### Real-time games need a high-level action, not per-frame controls + +An agent acts in discrete tool calls separated by network + model latency +(hundreds of ms to seconds). It physically CANNOT hit frame-timing, so exposing +only `invoke('flap')` makes a reflex game like Flappy Bird understandable but +unwinnable: by the time the agent decides to flap, the bird has already fallen. +For anything real-time, expose a **high-level action** the app executes on its +own tick loop, e.g. `invoke('autopilot', { on: true })` that runs the optimal +input internally, or `invoke('setDifficulty', ...)`. Let the agent set intent; +let the app handle the milliseconds. Non-real-time apps (tools, forms, a +Spotify-style player) don't need this: their actions are already at agent cadence. --- diff --git a/backend/apps/outputs/webapp_template/frontend/run.sh b/backend/apps/outputs/webapp_template/frontend/run.sh index 8c4e611c..4906bddf 100755 --- a/backend/apps/outputs/webapp_template/frontend/run.sh +++ b/backend/apps/outputs/webapp_template/frontend/run.sh @@ -18,6 +18,32 @@ FRONTEND_DIR_ABSPATH="$(dirname "$RUN_FRONTEND_ABSPATH")" cd "$FRONTEND_DIR_ABSPATH" +# --- Agent bridge check ------------------------------------------------------- +# Every OpenSwarm app must register window.OPENSWARM_APP so an agent can drive +# it. The plumbing ships in src/agentBridge.ts; what an app can still forget is +# to CALL register(...) with its own actions. Scan the app source for that call. +# Missing -> warn loudly but DON'T block (work-in-progress apps must still load). +# The real gate is the runtime check in the app agent; OPENSWARM_REQUIRE_BRIDGE=1 +# turns this into a hard failure for anyone who wants strict mode. +check_agent_bridge() { + local src_dir="$FRONTEND_DIR_ABSPATH/src" + [ -d "$src_dir" ] || return 0 + if grep -rEl --include='*.ts' --include='*.tsx' \ + 'OPENSWARM_APP\.register|window\.OPENSWARM_APP[[:space:]]*=' \ + "$src_dir" 2>/dev/null | grep -qv 'agentBridge\.'; then + return 0 + fi + echo "" + printf '\033[31m❌ BRIDGE MISSING: window.OPENSWARM_APP not registered - this app is not agent-operable.\033[0m\n' + printf '\033[31m Call window.OPENSWARM_APP.register({ rules, controls, getState, invoke }) when your app mounts (see SKILL.md).\033[0m\n' + echo "" + if [[ "${OPENSWARM_REQUIRE_BRIDGE:-}" == "1" ]]; then + echo "OPENSWARM_REQUIRE_BRIDGE=1 set; refusing to start without the agent bridge." + exit 1 + fi +} +check_agent_bridge + # Put the bundled Node on PATH so `npm`, `node`, and the vite child # processes all resolve even on a machine with no system Node. The # packaged Electron shell exports OPENSWARM_NODE_PATH (e.g. diff --git a/backend/apps/outputs/workspace_io.py b/backend/apps/outputs/workspace_io.py index ccb75b28..f4df157d 100644 --- a/backend/apps/outputs/workspace_io.py +++ b/backend/apps/outputs/workspace_io.py @@ -68,6 +68,7 @@ _WALK_SKIP_DIRS = frozenset({ ".pytest_cache", ".mypy_cache", ".ruff_cache", + ".openswarm", }) # Cap per-file response size at 256 KB. Hand-written source rarely diff --git a/backend/tests/test_app_agent.py b/backend/tests/test_app_agent.py index eb0d734b..1438eb4a 100644 --- a/backend/tests/test_app_agent.py +++ b/backend/tests/test_app_agent.py @@ -146,3 +146,98 @@ def test_selected_app_context_advertises_app_agent(monkeypatch): 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 _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 _no_sleep(_s): + return None + + monkeypatch.setattr(BA.ws_manager, "send_browser_command", _send, raising=False) + monkeypatch.setattr(BA.asyncio, "sleep", _no_sleep, raising=True) + monkeypatch.setattr(BA, "_persist_app_controls", lambda *a, **k: None, 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 _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 _no_sleep(_s): + return None + + monkeypatch.setattr(BA.ws_manager, "send_browser_command", _send, raising=False) + monkeypatch.setattr(BA.asyncio, "sleep", _no_sleep, raising=True) + + asyncio.run(BA.execute_browser_tool("AppInvoke", {"name": "flap"}, "app:abc")) + assert calls["n"] == 1 # single shot, no readiness wait + + +def test_app_describe_persists_controls_cache(monkeypatch, tmp_path): + ready = {"rules": "Keep the bird airborne.", "controls": [{"name": "flap", "keys": "Space"}], "__rev": 1} + + async def _send(request_id, action, browser_id, params, tab_id=""): + return {"text": json.dumps(ready)} + + monkeypatch.setattr(BA.ws_manager, "send_browser_command", _send, raising=False) + monkeypatch.setattr(BA, "_app_workspace_dir", lambda bid: str(tmp_path), raising=True) + + asyncio.run(BA.execute_browser_tool("AppDescribe", {}, "app:abc")) + controls = (tmp_path / ".openswarm" / "controls.md").read_text() + rules = (tmp_path / ".openswarm" / "rules.md").read_text() + assert "- `flap`" in controls and "[Space]" in controls + assert "Keep the bird airborne." in rules diff --git a/electron/package-lock.json b/electron/package-lock.json index 96e0bad9..0f70e4af 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "openswarm", - "version": "1.2.77", + "version": "1.2.79", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openswarm", - "version": "1.2.77", + "version": "1.2.79", "hasInstallScript": true, "dependencies": { "electron-updater": "6.8.3", @@ -567,7 +567,6 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1435,7 +1434,6 @@ "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", @@ -1584,7 +1582,6 @@ "integrity": "sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", @@ -2888,7 +2885,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" },