[eric] agents: a chat can edit the app it built, without the user re-selecting the card (ENG-416)

This commit is contained in:
ciregenz
2026-08-27 14:20:05 -07:00
parent fd2abfa467
commit e59b6efae1
3 changed files with 142 additions and 1 deletions
@@ -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()
@@ -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.
+105
View File
@@ -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