mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-07 18:27:45 +02:00
[eric] agents: a chat can edit the app it built, without the user re-selecting the card (ENG-416)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user