diff --git a/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py b/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py index f05f9ba0..e99841df 100644 --- a/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py +++ b/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py @@ -17,6 +17,7 @@ from backend.apps.agents.manager.prompt.prompt_context import ( build_installed_skills_catalog, build_mcp_registry_summary, build_selected_app_context, + build_unselected_app_context, build_selected_settings_context, compose_system_prompt, ) @@ -122,6 +123,9 @@ def compose_turn_system_prompt( # App cards the user picked via the dashboard element picker: give the agent each app's on-disk path + meta + SKILL.md pointer so it can edit them in place (the dashboard card's runtime live-reloads). Additive and independent of view-builder mode above. app_ctx = build_selected_app_context(selected_app_output_ids) + # Nothing picked: name the apps anyway so the agent can point the user at the selection step instead of acting like their app does not exist. + if not app_ctx: + app_ctx = build_unselected_app_context() if app_ctx: composed_prompt = f"{composed_prompt}\n\n{app_ctx}" if composed_prompt else app_ctx diff --git a/backend/apps/agents/manager/prompt/prompt_context.py b/backend/apps/agents/manager/prompt/prompt_context.py index b9ae5bb3..e9d8a75e 100644 --- a/backend/apps/agents/manager/prompt/prompt_context.py +++ b/backend/apps/agents/manager/prompt/prompt_context.py @@ -132,6 +132,39 @@ def build_browser_context(dashboard_id: Optional[str], selected_browser_ids: Opt return "\n".join(lines) +@typechecked +def build_unselected_app_context() -> Optional[str]: + """Name the user's existing apps when none is selected. + + Selecting a card is what grants edit access, but a user who has not learned that ritual just + sees an agent that appears not to know their app exists, and the agent cannot tell them what to + do because it was never told the app was there either. Names only, no paths: enough to say + "select it and I can edit it", not enough to start editing something the user did not point at. + """ + from backend.apps.outputs.workspace_io import load_all + try: + apps = [o for o in load_all() if o.workspace_id and not getattr(o, "deleted_at", None)] + except Exception: + return None + if not apps: + return None + names = ", ".join(f'"{o.name or "Untitled App"}"' for o in apps[:P_UNSELECTED_APP_CAP]) + more = "" if len(apps) <= P_UNSELECTED_APP_CAP else f", and {len(apps) - P_UNSELECTED_APP_CAP} more" + return ( + "\n" + f"The user has these Apps on their dashboard: {names}{more}.\n" + "You do NOT have access to their files right now. If the user asks you to change, look at, " + "or use one of them, say so plainly and tell them to click the App card to select it, then " + "resend. Do not guess at file paths, do not scaffold a replacement, and do not claim the app " + "does not exist.\n" + "" + ) + + +# A long list burns prompt tokens for no gain; past a handful the agent only needs to know apps exist. +P_UNSELECTED_APP_CAP = 12 + + @typechecked def build_selected_app_context(selected_app_output_ids: Optional[List[str]]) -> Optional[str]: """Build a context block for dashboard App cards the user selected to edit. diff --git a/backend/tests/test_unselected_app_context.py b/backend/tests/test_unselected_app_context.py new file mode 100644 index 00000000..aad4dcf5 --- /dev/null +++ b/backend/tests/test_unselected_app_context.py @@ -0,0 +1,116 @@ +"""An agent that cannot see an app must still know it exists. + +Selecting an App card is what grants edit access, and that gate is deliberate. But a user who has +not learned the ritual just sees an agent behaving as though their app was never built, and the +agent cannot explain the problem because it was never told the app was there either. So with +nothing selected we name the apps and nothing more: enough to say "select it and I can edit it", +not enough to start editing something the user never pointed at. + +Run: + cd backend && .venv/bin/python -m pytest tests/test_unselected_app_context.py -v +""" + +from __future__ import annotations + +from backend.apps.agents.manager.prompt import prompt_context +from backend.apps.outputs.models import Output + + +def p_app(name: str, workspace_id: str = "ws") -> Output: + return Output(name=name, workspace_id=workspace_id) + + +def test_names_the_apps_when_nothing_is_selected(monkeypatch): + monkeypatch.setattr(prompt_context, "load_all", lambda: [p_app("Budget Tracker")], raising=False) + monkeypatch.setattr( + "backend.apps.outputs.workspace_io.load_all", + lambda: [p_app("Budget Tracker"), p_app("Habit Log")], + ) + + out = prompt_context.build_unselected_app_context() + + assert out is not None + assert "Budget Tracker" in out and "Habit Log" in out + + +def test_tells_the_agent_to_ask_for_a_selection(monkeypatch): + """The whole point: the agent has to hand the user the next step, not stonewall them.""" + monkeypatch.setattr("backend.apps.outputs.workspace_io.load_all", lambda: [p_app("Budget Tracker")]) + + out = prompt_context.build_unselected_app_context() or "" + + assert "select" in out.lower() + assert "does not exist" in out, "must explicitly forbid claiming the app is missing" + + +def test_leaks_no_paths(monkeypatch): + """Names only. A path here would let the agent edit an app the user never pointed at, which is + exactly the access the selection gate exists to withhold.""" + monkeypatch.setattr("backend.apps.outputs.workspace_io.load_all", lambda: [p_app("Budget Tracker")]) + + out = prompt_context.build_unselected_app_context() or "" + + assert "/" not in out.replace("", ""), "no filesystem paths may appear" + assert "workspace" not in out.lower() + + +def test_silent_when_the_user_has_no_apps(monkeypatch): + monkeypatch.setattr("backend.apps.outputs.workspace_io.load_all", lambda: []) + assert prompt_context.build_unselected_app_context() is None + + +def test_skips_apps_with_no_workspace(monkeypatch): + """A row without a workspace is not a real app the user can select.""" + monkeypatch.setattr( + "backend.apps.outputs.workspace_io.load_all", + lambda: [Output(name="Ghost", workspace_id=None)], + ) + assert prompt_context.build_unselected_app_context() is None + + +def test_caps_a_long_list(monkeypatch): + many = [p_app(f"App {i}") for i in range(30)] + monkeypatch.setattr("backend.apps.outputs.workspace_io.load_all", lambda: many) + + out = prompt_context.build_unselected_app_context() or "" + + assert "App 0" in out + assert "App 29" not in out, "an uncapped list burns prompt tokens on every turn" + assert "more" in out, "the user must still learn there are others" + + +def test_a_broken_store_does_not_break_the_turn(monkeypatch): + def p_boom(): + raise OSError("disk gone") + + monkeypatch.setattr("backend.apps.outputs.workspace_io.load_all", p_boom) + assert prompt_context.build_unselected_app_context() is None + + +def test_selection_still_wins(monkeypatch): + """With a real selection the rich block must be used; this fallback must not shadow it.""" + monkeypatch.setattr("backend.apps.outputs.workspace_io.load_all", lambda: [p_app("Budget Tracker")]) + selected = prompt_context.build_selected_app_context(None) + assert selected is None, "no ids means no rich block, which is what triggers the fallback" + + +def test_the_block_actually_reaches_the_composed_prompt(monkeypatch): + """The one that matters. Every test above passes even with the builder unwired from the prompt, + so without this the feature could ship doing nothing at all.""" + from backend.apps.agents.core.models import AgentSession + from backend.apps.agents.manager.prompt.compose_turn_system_prompt import compose_turn_system_prompt + + monkeypatch.setattr("backend.apps.outputs.workspace_io.load_all", lambda: [p_app("Budget Tracker")]) + session = AgentSession(id="s1", name="t", model="sonnet", mode="agent") + + composed = compose_turn_system_prompt( + session=session, + mode_sys_prompt=None, + default_system_prompt=None, + selected_browser_ids=None, + selected_app_output_ids=None, + selected_setting_ids=None, + ) or "" + + assert "Budget Tracker" in composed + assert "" in composed