diff --git a/app/api/routes/device_ws.py b/app/api/routes/device_ws.py index 46b36e1..c731058 100644 --- a/app/api/routes/device_ws.py +++ b/app/api/routes/device_ws.py @@ -374,14 +374,15 @@ async def _handle_floating_request( # ── v8 Contextual Sidebar Handlers ─────────────────────────────────── -def get_session_buffer(session_id: str, channel: str = "contextual"): - """Return the session buffer for the given session. +def get_session_buffer(user_id: str, session_id: str, channel: str = "contextual"): + """Return a session-scoped buffer proxy for the given user+session. - The channel kwarg is accepted for forward-compatibility but not used for - namespacing yet (session ids are UUIDs so collisions are negligible). + Returns a _ContextualBufferProxy that exposes append_system_message(). Defined at module level so tests can monkeypatch it. + The channel kwarg is accepted for forward-compatibility. """ - return session_buffer + from app.core.agent_session_buffer import ContextualBufferProxy # noqa: PLC0415 + return ContextualBufferProxy(session_buffer, user_id, session_id) async def _handle_contextual_request( @@ -472,7 +473,7 @@ async def _handle_contextual_scope_update( session_id: str = frame.get("session_id") or str(uuid4()) scope = ContextualScope.model_validate(frame.get("scope", {})) block = render_scope_block(scope) - buf = get_session_buffer(session_id, channel="contextual") + buf = get_session_buffer(user_id, session_id, channel="contextual") buf.append_system_message( f"User navigated to a new view. {block} Treat this as the new active context." ) diff --git a/app/core/agent_session_buffer.py b/app/core/agent_session_buffer.py index 87cdd03..4203472 100644 --- a/app/core/agent_session_buffer.py +++ b/app/core/agent_session_buffer.py @@ -54,6 +54,43 @@ class _SessionBuffer: with self._lock: self._store.pop((user_id, session_id), None) + def append_system_message(self, user_id: str, session_id: str, text: str) -> None: + """Append a synthetic system message to the buffer for the given session. + + Creates the session slot if it does not yet exist. Used by the + contextual_scope_update handler to inject navigation events without + making an LLM call. + """ + from langchain_core.messages import SystemMessage # noqa: PLC0415 + + key = (user_id, session_id) + with self._lock: + entry = self._store.get(key) + if entry is None: + msgs: list[BaseMessage] = [SystemMessage(content=text)] + else: + _, existing = entry + msgs = list(existing) + [SystemMessage(content=text)] + capped = msgs[-MAX_MESSAGES_PER_SESSION:] + self._store[key] = (time.monotonic(), capped) + + +class ContextualBufferProxy: + """Thin wrapper around _SessionBuffer that closes over user_id + session_id. + + Returned by get_session_buffer() so callers can call + ``proxy.append_system_message(text)`` without threading user_id/session_id + through every call site. + """ + + def __init__(self, buf: "_SessionBuffer", user_id: str, session_id: str) -> None: + self._buf = buf + self._user_id = user_id + self._session_id = session_id + + def append_system_message(self, text: str) -> None: + self._buf.append_system_message(self._user_id, self._session_id, text) + # Module-level singleton — same pattern as _pending_states in api/app/api/routes/auth.py session_buffer = _SessionBuffer()