mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] agents: consolidate shadowed /compact + /clear routes into one handler each + duplicate-route guard test
This commit is contained in:
@@ -308,16 +308,19 @@ async def warm_session_cache(session_id: str):
|
||||
async def compact_session(session_id: str):
|
||||
"""Run the summarizer over older turns to free up context.
|
||||
|
||||
Wired to the 'Compact memory' button in the pre-send overflow banner
|
||||
and the /compact slash command. Sets compacted_through_msg_id so the
|
||||
next turn's history-builder uses the summary in place of the
|
||||
original messages.
|
||||
Wired to the 'Compact memory' button in the pre-send overflow banner and the
|
||||
/compact slash command. Marks compacted_through_msg_id AND sets
|
||||
needs_fresh_session: the user explicitly opted into the prompt-cache loss for a
|
||||
real visible trim, so the next turn drops the SDK convo and rebuilds from history
|
||||
with the cutoff (and distilled summary) actually applied. Auto-compact only marks;
|
||||
the button is the user paying for the rebuild.
|
||||
"""
|
||||
session = agent_manager.sessions.get(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="session not found")
|
||||
fired = agent_manager.maybe_compact(session, force=True)
|
||||
if fired:
|
||||
session.needs_fresh_session = True
|
||||
from backend.apps.agents.core.ws_manager import ws_manager
|
||||
try:
|
||||
await ws_manager.send_to_session(session_id, "agent:context_status", {
|
||||
@@ -347,6 +350,8 @@ async def clear_session(session_id: str):
|
||||
raise HTTPException(status_code=404, detail="session not found")
|
||||
session.messages = []
|
||||
session.compacted_through_msg_id = None
|
||||
session.compacted_summary = None
|
||||
session.compacted_summary_through = None
|
||||
session.tokens = {"input": 0, "output": 0}
|
||||
session.needs_fresh_session = True
|
||||
from backend.apps.agents.core.ws_manager import ws_manager
|
||||
|
||||
@@ -863,61 +863,6 @@ async def settings_meta(action: str, request: Request):
|
||||
return JSONResponse({"error": f"unknown action: {action}"}, status_code=400)
|
||||
|
||||
|
||||
@app.post("/api/agents/sessions/{session_id}/compact")
|
||||
async def session_compact(session_id: str):
|
||||
"""Force a compaction pass on a session (Phase 2 /compact slash cmd).
|
||||
|
||||
User explicitly clicked compact, so we accept the prompt-cache loss in exchange
|
||||
for a real visible trim: needs_fresh_session drops the SDK convo so the next turn
|
||||
rebuilds from history with compacted_through_msg_id actually applied (auto-compact
|
||||
only sets the marker; the button is the user opting into the cost).
|
||||
"""
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
from backend.apps.agents.core.ws_manager import ws_manager as p_ws
|
||||
session = agent_manager.sessions.get(session_id)
|
||||
if not session:
|
||||
return JSONResponse({"error": "session not found"}, status_code=404)
|
||||
did_compact = agent_manager.maybe_compact(session, force=True)
|
||||
if did_compact:
|
||||
session.needs_fresh_session = True
|
||||
await p_ws.send_to_session(session_id, "agent:context_status", {
|
||||
"session_id": session_id,
|
||||
"reason": "compacted_manual" if did_compact else "noop",
|
||||
"compacted_through_msg_id": session.compacted_through_msg_id,
|
||||
})
|
||||
return JSONResponse({"compacted": did_compact, "compacted_through_msg_id": session.compacted_through_msg_id})
|
||||
|
||||
|
||||
@app.post("/api/agents/sessions/{session_id}/clear")
|
||||
async def session_clear(session_id: str):
|
||||
"""Wipe the session's UI history AND its SDK convo state (/clear slash cmd, Reset history button)."""
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
from backend.apps.agents.core.ws_manager import ws_manager as p_ws
|
||||
from backend.apps.agents.core.models import MessageBranch
|
||||
session = agent_manager.sessions.get(session_id)
|
||||
if not session:
|
||||
return JSONResponse({"error": "session not found"}, status_code=404)
|
||||
session.sdk_session_id = None
|
||||
session.active_mcps = []
|
||||
session.compacted_through_msg_id = None
|
||||
session.tokens = {"input": 0, "output": 0}
|
||||
session.cost_usd = 0.0
|
||||
session.needs_fork = False
|
||||
session.messages = []
|
||||
session.pending_approvals = []
|
||||
session.branches = {"main": MessageBranch(id="main")}
|
||||
session.active_branch_id = "main"
|
||||
session.tool_group_meta = {}
|
||||
await p_ws.send_to_session(session_id, "agent:status", {
|
||||
"session_id": session_id,
|
||||
"status": session.status,
|
||||
"session": session.model_dump(mode="json"),
|
||||
})
|
||||
await p_ws.send_to_session(session_id, "agent:context_status", {
|
||||
"session_id": session_id,
|
||||
"reason": "cleared",
|
||||
})
|
||||
return JSONResponse({"cleared": True})
|
||||
|
||||
|
||||
@app.post("/api/invoke-agent/run")
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""The /compact endpoint must actually trigger a rebuild, not just mark.
|
||||
|
||||
The bug: two handlers registered POST .../compact; the live one (agents.py) only set
|
||||
the compaction marker, so /compact never dropped the SDK session and the trim (and the
|
||||
distilled summary) was never applied, the button silently did nothing visible. After
|
||||
consolidating to one handler, /compact sets needs_fresh_session so the next turn rebuilds.
|
||||
This pins that wiring against the real route.
|
||||
"""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.main import app
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
from backend.apps.agents.core.models import AgentSession, Message
|
||||
|
||||
|
||||
def p_client() -> TestClient:
|
||||
import backend.auth as auth_mod
|
||||
if not auth_mod.TOKEN:
|
||||
import secrets
|
||||
auth_mod.TOKEN = secrets.token_urlsafe(32)
|
||||
return TestClient(app, headers={"Authorization": f"Bearer {auth_mod.TOKEN}"})
|
||||
|
||||
|
||||
def p_seed(n: int) -> AgentSession:
|
||||
s = AgentSession(name="t", model="sonnet")
|
||||
s.context_window = 100
|
||||
s.tokens = {"input": 90, "output": 0} # 0.90 -> over threshold
|
||||
s.messages = [Message(role="user", content=f"m{i}") for i in range(n)]
|
||||
s.sdk_session_id = "sdk-live-123"
|
||||
agent_manager.sessions[s.id] = s
|
||||
return s
|
||||
|
||||
|
||||
def test_compact_sets_needs_fresh_session_so_it_rebuilds() -> None:
|
||||
s = p_seed(10)
|
||||
try:
|
||||
r = p_client().post(f"/api/agents/sessions/{s.id}/compact")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["compacted"] is True
|
||||
assert s.compacted_through_msg_id is not None
|
||||
# The whole point: the button opts into the rebuild, so the next turn drops the SDK convo and applies the cutoff/distill.
|
||||
assert s.needs_fresh_session is True
|
||||
finally:
|
||||
agent_manager.sessions.pop(s.id, None)
|
||||
|
||||
|
||||
def test_compact_noop_when_nothing_to_trim_leaves_state_clean() -> None:
|
||||
s = p_seed(3) # too few messages to compact
|
||||
try:
|
||||
r = p_client().post(f"/api/agents/sessions/{s.id}/compact")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["compacted"] is False
|
||||
assert s.needs_fresh_session is False
|
||||
finally:
|
||||
agent_manager.sessions.pop(s.id, None)
|
||||
|
||||
|
||||
def test_compact_unknown_session_404() -> None:
|
||||
r = p_client().post("/api/agents/sessions/no-such-session/compact")
|
||||
assert r.status_code == 404
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Route-collision guard: no two handlers may register the same (method, path).
|
||||
|
||||
The bug class: two files registered POST /api/agents/sessions/{id}/compact (and
|
||||
/clear). Starlette silently serves the first-registered one, so the second handler
|
||||
was dead code AND the live one had the wrong behavior (marker-only /compact never
|
||||
rebuilt). Nothing surfaced it, because a duplicate route is not an error to Starlette.
|
||||
|
||||
The seal: enumerate the built app's routes and fail on any duplicate (method, path).
|
||||
A shadowed route can never ship again; the machine catches it, not a human months later.
|
||||
"""
|
||||
|
||||
from collections import Counter
|
||||
|
||||
from backend.main import app
|
||||
|
||||
|
||||
def test_no_duplicate_method_path_routes() -> None:
|
||||
pairs = []
|
||||
for route in app.routes:
|
||||
path = getattr(route, "path", None)
|
||||
methods = getattr(route, "methods", None)
|
||||
if path is None or not methods:
|
||||
continue
|
||||
for method in methods:
|
||||
pairs.append((method, path))
|
||||
dupes = [pair for pair, n in Counter(pairs).items() if n > 1]
|
||||
assert not dupes, (
|
||||
"Duplicate route registrations (one silently shadows the other; "
|
||||
f"consolidate to a single handler): {sorted(dupes)}"
|
||||
)
|
||||
Reference in New Issue
Block a user