Pre-flight Checklist
Problem or Motivation
Summary
SessionManager currently ignores the CopilotKit threadId when creating the underlying ADK session, generating a random UUID instead. This makes it impossible to do an O(1) session lookup by threadId and forces an expensive list_sessions + linear scan on every reconnect.
I'd like to propose a use_thread_id_as_session_id flag that lets integrators use the threadId directly as the ADK session_id.
Problem
When CopilotKit connects, it sends a threadId that serves as the canonical conversation identifier on the frontend. The current SessionManager flow:
- Creates a session via
DatabaseSessionService.create_session without passing session_id, so the database generates a random UUID.
- Stores the
threadId inside session.state["_ag_ui_thread_id"].
- On reconnect, calls
list_sessions and does a linear scan over every session's state to find the one whose _ag_ui_thread_id matches — an O(n) operation that degrades as sessions accumulate.
This causes several issues:
| Issue |
Impact |
threadId ≠ session.id |
Cannot build a shareable URL like /chat/{threadId} that maps directly to a DB row |
| Reconnect requires full table scan |
Latency grows linearly with the number of sessions |
/agents/state resolution |
Must repeat the same list-and-scan to resolve a threadId |
| Race conditions on reconnect |
Two concurrent requests can each create a new session for the same threadId because the scan-then-create is not atomic |
Proposed Solution
Proposed Solution
Add a use_thread_id_as_session_id boolean flag (default False) to ADKAgent / SessionManager:
ADKAgent(
...,
use_thread_id_as_session_id=True,
)
When the flag is True:
get_or_create_session passes session_id=thread_id to create_session instead of letting the database generate a random UUID.
_find_session_by_thread_id attempts a direct get_session(session_id=thread_id) first (O(1) primary-key lookup), falling back to the existing list_sessions scan only for backward compatibility with sessions created before the flag was enabled.
When the flag is False (default), behaviour is identical to today — fully backward compatible.
Handling concurrent creates
When session_id=thread_id is passed, two concurrent requests for the same threadId can race past the existence check. The create path should catch the resulting AlreadyExistsError / IntegrityError and fall back to get_session:
try:
session = await self._session_service.create_session(
..., session_id=thread_id,
)
except (AlreadyExistsError, IntegrityError):
session = await self._session_service.get_session(
session_id=thread_id, app_name=app_name, user_id=user_id,
)
Alternatives Considered
No response
Additional Context
Current Workaround
We are currently monkey-patching SessionManager.get_or_create_session and SessionManager._find_session_by_thread_id at startup to achieve this. The patch works reliably in production with ag-ui-adk ~=0.5.0 and google-adk ~=1.25.1, but it's fragile — any internal refactor of SessionManager could break it.
Monkey-patch implementation (click to expand)
"""Patch ag_ui_adk SessionManager to use threadId as the ADK session_id."""
from __future__ import annotations
import logging
from typing import Any, Dict, Optional, Tuple
from google.adk.errors.already_exists_error import AlreadyExistsError
from sqlalchemy.exc import IntegrityError
from ag_ui_adk.session_manager import (
APP_NAME_STATE_KEY,
THREAD_ID_STATE_KEY,
USER_ID_STATE_KEY,
SessionManager,
)
logger = logging.getLogger(__name__)
async def _get_or_create_session_using_thread_id(
self: SessionManager,
thread_id: str,
app_name: str,
user_id: str,
initial_state: Optional[Dict[str, Any]] = None,
) -> Tuple[Any, str]:
if self._max_per_user:
user_count = len(self._user_sessions.get(user_id, set()))
if user_count >= self._max_per_user:
await self._remove_oldest_user_session(user_id)
session = await self._find_session_by_thread_id(app_name, user_id, thread_id)
if session:
session_key = self._make_session_key(app_name, session.id)
self._track_session(session_key, user_id)
return session, session.id
state = {
**(initial_state or {}),
THREAD_ID_STATE_KEY: thread_id,
APP_NAME_STATE_KEY: app_name,
USER_ID_STATE_KEY: user_id,
}
try:
session = await self._session_service.create_session(
user_id=user_id,
app_name=app_name,
state=state,
session_id=thread_id, # <-- the key change
)
except (AlreadyExistsError, IntegrityError):
session = await self._session_service.get_session(
session_id=thread_id, app_name=app_name, user_id=user_id,
)
if session is None:
raise RuntimeError(
f"Session {thread_id} reported as duplicate but could not be read back"
)
session_key = self._make_session_key(app_name, session.id)
self._track_session(session_key, user_id)
if not self._cleanup_task:
self._start_cleanup_task()
return session, session.id
async def _find_session_by_thread_id_direct(
self: SessionManager,
app_name: str,
user_id: str,
thread_id: str,
) -> Optional[Any]:
# O(1) lookup — session_id IS the thread_id
try:
session = await self._session_service.get_session(
session_id=thread_id, app_name=app_name, user_id=user_id,
)
if session:
return session
except Exception:
pass
# Fallback for legacy sessions (thread_id stored in state)
if hasattr(self._session_service, "list_sessions"):
response = await self._session_service.list_sessions(
app_name=app_name, user_id=user_id,
)
for session in response.sessions:
if session.state and session.state.get(THREAD_ID_STATE_KEY) == thread_id:
return session
return None
def apply_session_id_patch() -> None:
SessionManager.get_or_create_session = _get_or_create_session_using_thread_id
SessionManager._find_session_by_thread_id = _find_session_by_thread_id_direct
Expected Benefits
| Before |
After |
threadId ≠ session.id (random UUID) |
threadId == session.id |
Reconnect = list_sessions + O(n) scan |
Reconnect = get_session(id) O(1) |
| Cannot derive DB row from frontend ID |
/chat/{threadId} maps directly to a DB row |
| Concurrent creates can produce duplicates |
Race condition handled gracefully |
Environment
ag-ui-adk: ~=0.5.0
google-adk: ~=1.25.1
- Session backend:
DatabaseSessionService (PostgreSQL)
- Frontend: CopilotKit
Pre-flight Checklist
Problem or Motivation
Summary
SessionManagercurrently ignores the CopilotKitthreadIdwhen creating the underlying ADK session, generating a random UUID instead. This makes it impossible to do an O(1) session lookup bythreadIdand forces an expensivelist_sessions+ linear scan on every reconnect.I'd like to propose a
use_thread_id_as_session_idflag that lets integrators use thethreadIddirectly as the ADKsession_id.Problem
When CopilotKit connects, it sends a
threadIdthat serves as the canonical conversation identifier on the frontend. The currentSessionManagerflow:DatabaseSessionService.create_sessionwithout passingsession_id, so the database generates a random UUID.threadIdinsidesession.state["_ag_ui_thread_id"].list_sessionsand does a linear scan over every session's state to find the one whose_ag_ui_thread_idmatches — an O(n) operation that degrades as sessions accumulate.This causes several issues:
threadId ≠ session.id/chat/{threadId}that maps directly to a DB row/agents/stateresolutionthreadIdthreadIdbecause the scan-then-create is not atomicProposed Solution
Proposed Solution
Add a
use_thread_id_as_session_idboolean flag (defaultFalse) toADKAgent/SessionManager:When the flag is
True:get_or_create_sessionpassessession_id=thread_idtocreate_sessioninstead of letting the database generate a random UUID._find_session_by_thread_idattempts a directget_session(session_id=thread_id)first (O(1) primary-key lookup), falling back to the existinglist_sessionsscan only for backward compatibility with sessions created before the flag was enabled.When the flag is
False(default), behaviour is identical to today — fully backward compatible.Handling concurrent creates
When
session_id=thread_idis passed, two concurrent requests for the samethreadIdcan race past the existence check. The create path should catch the resultingAlreadyExistsError/IntegrityErrorand fall back toget_session:Alternatives Considered
No response
Additional Context
Current Workaround
We are currently monkey-patching
SessionManager.get_or_create_sessionandSessionManager._find_session_by_thread_idat startup to achieve this. The patch works reliably in production withag-ui-adk ~=0.5.0andgoogle-adk ~=1.25.1, but it's fragile — any internal refactor ofSessionManagercould break it.Monkey-patch implementation (click to expand)
Expected Benefits
threadId ≠ session.id(random UUID)threadId == session.idlist_sessions+ O(n) scanget_session(id)O(1)/chat/{threadId}maps directly to a DB rowEnvironment
ag-ui-adk:~=0.5.0google-adk:~=1.25.1DatabaseSessionService(PostgreSQL)