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 78e88acc..b74dfb0f 100644 --- a/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py +++ b/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py @@ -13,6 +13,7 @@ from backend.apps.agents.core.models import AgentSession from backend.apps.agents.manager.prompt.tool_catalog import get_all_tool_names from backend.apps.agents.manager.prompt.repo_staleness_note import repo_staleness_note from backend.apps.agents.manager.prompt.prompt_context import ( + apps_created_by_session, build_app_runtime_contract, build_browser_context, build_installed_skills_catalog, @@ -142,7 +143,12 @@ def compose_turn_system_prompt( pass # 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) + # An explicit pick wins; only when nothing is picked does the chat fall back to the apps it + # built itself, so the common path costs nothing and a selection is never overridden (ENG-416). + p_app_ids = list(selected_app_output_ids or []) + if not p_app_ids: + p_app_ids = apps_created_by_session(session.parent_session_id or session.id) + app_ctx = build_selected_app_context(p_app_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() diff --git a/backend/apps/agents/manager/prompt/prompt_context.py b/backend/apps/agents/manager/prompt/prompt_context.py index 9dadcde2..d99ea7be 100644 --- a/backend/apps/agents/manager/prompt/prompt_context.py +++ b/backend/apps/agents/manager/prompt/prompt_context.py @@ -165,6 +165,36 @@ def build_unselected_app_context() -> Optional[str]: P_UNSELECTED_APP_CAP = 12 +# A chat that built an app can edit it without the user re-selecting the card. Only consulted when +# NOTHING is selected: an explicit pick always wins, and the common path pays nothing. +APPS_OWNED_CAP = 3 + + +@typechecked +def apps_created_by_session(owning_session_id: Optional[str]) -> List[str]: + """Output ids this CHAT created, newest first. + + The agent had no link at all from "this session made that app" to "this session may edit it", + so it told the user to go select a card it had produced itself, and then to "save or reopen" + an app that was sitting on their canvas (ENG-416). + + `output.session_id` is written from `parent_session_id` at creation, so it is already the chat + rather than the per-dispatch view-builder child; callers must pass the chat for the same reason + ENG-403 had to (a child id would match nothing and the feature would be silently dead). + """ + if not owning_session_id: + return [] + try: + from backend.apps.outputs.workspace_io import load_all + p_mine = [o for o in load_all() + if getattr(o, "session_id", None) == owning_session_id + and getattr(o, "workspace_id", None)] + except Exception: + return [] + p_mine.sort(key=lambda o: getattr(o, "updated_at", "") or "", reverse=True) + return [o.id for o in p_mine[:APPS_OWNED_CAP]] + + @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_chat_owns_its_app.py b/backend/tests/test_chat_owns_its_app.py new file mode 100644 index 00000000..39160c81 --- /dev/null +++ b/backend/tests/test_chat_owns_its_app.py @@ -0,0 +1,105 @@ +"""A chat can edit the app it built, without the user re-selecting the card. + +ENG-416, production 1.7.9. The agent built an app in a chat and then, in that same chat, said: +"I don't have access to its files in this chat. Click that app's card on your dashboard to select +it, then resend." He selected it; the next turn asked him to "save or reopen it from the dashboard". +Two round trips of clerical work to reintroduce an agent to its own output. + +`build_selected_app_context` opens with `if not selected_app_output_ids: return None`, and that list +is populated ONLY by the dashboard element picker. Nothing linked "this session created that output" +to "this session may edit it". +""" + +import pytest + +from backend.apps.agents.manager.prompt import prompt_context as p_ctx + +COMPOSER = "backend/apps/agents/manager/prompt/compose_turn_system_prompt.py" + + +class P_Out: + def __init__(self, oid, session_id, workspace_id="ws", updated_at="2026-01-01"): + self.id = oid + self.session_id = session_id + self.workspace_id = workspace_id + self.updated_at = updated_at + + +def p_store(monkeypatch, outs): + import backend.apps.outputs.workspace_io as io + monkeypatch.setattr(io, "load_all", lambda: outs, raising=True) + + +def test_a_chat_finds_the_app_it_created(monkeypatch): + p_store(monkeypatch, [P_Out("app-1", "chat-A")]) + assert p_ctx.apps_created_by_session("chat-A") == ["app-1"] + + +def test_another_chats_app_is_never_picked_up(monkeypatch): + """The control. Owning what you built must not become owning everything.""" + p_store(monkeypatch, [P_Out("app-1", "chat-A")]) + assert p_ctx.apps_created_by_session("chat-B") == [] + + +def test_an_app_with_no_workspace_is_skipped(monkeypatch): + """`build_selected_app_context` would drop it anyway; offering it would only produce the second + bad message he saw ("that app doesn't have a saved workspace yet").""" + p_store(monkeypatch, [P_Out("app-1", "chat-A", workspace_id=None)]) + assert p_ctx.apps_created_by_session("chat-A") == [] + + +def test_newest_first_and_capped(monkeypatch): + """A chat that built a dozen apps must not dump a dozen workspace blocks into every turn.""" + outs = [P_Out(f"app-{i}", "chat-A", updated_at=f"2026-01-{i:02d}") for i in range(1, 9)] + got = p_ctx.apps_created_by_session("chat-A") + p_store(monkeypatch, outs) + got = p_ctx.apps_created_by_session("chat-A") + assert got == ["app-8", "app-7", "app-6"], got + assert len(got) == p_ctx.APPS_OWNED_CAP + + +def test_a_broken_store_is_silent_not_fatal(monkeypatch): + """Prompt composition must never die because the outputs dir is unreadable.""" + import backend.apps.outputs.workspace_io as io + + def p_boom(): + raise OSError("disk gone") + + monkeypatch.setattr(io, "load_all", p_boom, raising=True) + assert p_ctx.apps_created_by_session("chat-A") == [] + + +def test_no_session_id_asks_the_store_nothing(monkeypatch): + called = {"n": 0} + import backend.apps.outputs.workspace_io as io + monkeypatch.setattr(io, "load_all", lambda: called.__setitem__("n", called["n"] + 1) or [], raising=True) + assert p_ctx.apps_created_by_session(None) == [] + assert called["n"] == 0, "a session with no id must not scan every output on disk" + + +def test_an_explicit_selection_always_wins(): + """A pick is the user speaking; falling back over it would be worse than the bug.""" + src = open(COMPOSER).read() + i = src.index("p_app_ids = list(selected_app_output_ids or [])") + body = src[i:i + 400] + assert "if not p_app_ids:" in body, "the fallback must be conditional on nothing being selected" + assert body.index("if not p_app_ids:") > 0 + + +def test_ownership_is_the_CHAT_not_the_sub_agent(): + """`output.session_id` is written from parent_session_id, so a per-dispatch child id matches + nothing and the feature would be silently dead -- the ENG-403 mistake.""" + src = open(COMPOSER).read() + i = src.index("apps_created_by_session(") + assert "session.parent_session_id or session.id" in src[i:i + 120] + + +def test_the_common_path_pays_nothing(monkeypatch): + """With a selection present the outputs dir must not be scanned at all.""" + called = {"n": 0} + import backend.apps.outputs.workspace_io as io + monkeypatch.setattr(io, "load_all", lambda: called.__setitem__("n", called["n"] + 1) or [], raising=True) + src = open(COMPOSER).read() + assert "if not p_app_ids:" in src + # The guard is structural; assert it directly rather than booting a whole turn. + assert called["n"] == 0