diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index 8ffa91c6..8f70b0c7 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -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 diff --git a/backend/main.py b/backend/main.py index 91ca4b38..37b09c74 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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") diff --git a/backend/tests/test_compact_endpoint.py b/backend/tests/test_compact_endpoint.py new file mode 100644 index 00000000..acc1101a --- /dev/null +++ b/backend/tests/test_compact_endpoint.py @@ -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 diff --git a/backend/tests/test_no_duplicate_routes.py b/backend/tests/test_no_duplicate_routes.py new file mode 100644 index 00000000..59de4b6c --- /dev/null +++ b/backend/tests/test_no_duplicate_routes.py @@ -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)}" + )