Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions libs/code/deepagents_code/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -5501,6 +5501,24 @@ async def _prewarm_threads_cache(self) -> None: # noqa: PLR6301 # Worker hook

await prewarm_thread_message_counts(limit=get_thread_limit())

def _schedule_thread_cache_refresh(self) -> None:
"""Refresh the cached `/threads` rows in the background.

The selector paints from the in-memory cache first and only re-queries
the session database afterwards, so a thread created mid-session would
otherwise be missing from the first paint until that query, the agent
scan, and a full row rebuild finish. Refreshing once a turn has written
its checkpoints keeps the cache current, so `/threads` shows the new
thread (and its updated timestamp and message count) immediately.
"""
if self._exit or not self.is_running:
return
self.run_worker(
self._prewarm_threads_cache,
exclusive=True,
group="thread-cache-refresh",
)

async def _prewarm_model_caches(self) -> None:
"""Prewarm model discovery and profile caches without blocking startup."""
try:
Expand Down Expand Up @@ -15142,6 +15160,9 @@ async def _cleanup_agent_task(
# `_goal_state_lock` — would never be woken and would deadlock.
if not self._agent_running and not self._agent_reconciling:
self._agent_quiescent.set()
# Scheduled after goal reconciliation so the refreshed rows include
# every checkpoint this turn produced.
self._schedule_thread_cache_refresh()

@staticmethod
def _convert_messages_to_data(messages: list[Any]) -> list[MessageData]:
Expand Down
8 changes: 7 additions & 1 deletion libs/code/deepagents_code/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -499,7 +499,13 @@ async def prewarm_thread_message_counts(limit: int | None = None) -> None:

Fetches a bounded list of recent threads and populates checkpoint-derived
fields for currently visible columns into the in-memory cache. Intended to
run in a background worker during app startup.
run in a background worker during app startup and again whenever the
session database has changed (e.g. after a turn writes new checkpoints), so
the selector's first paint is never missing a thread the user just created.

Re-running this is cheap: the per-thread message-count and initial-prompt
caches are keyed on checkpoint freshness, so only threads whose latest
checkpoint changed are read back from disk.

Args:
limit: Maximum threads to prewarm. Uses `get_thread_limit()` when `None`.
Expand Down
41 changes: 41 additions & 0 deletions libs/code/tests/unit_tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -820,6 +820,34 @@ async def test_cleanup_agent_task_schedules_git_branch_refresh(self) -> None:
drain_mock.assert_awaited_once()
queue_mock.assert_awaited_once()

async def test_cleanup_agent_task_refreshes_thread_cache(self) -> None:
"""Agent cleanup should refresh cached `/threads` rows after a turn."""
app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
refresh_mock = MagicMock()
app._process_next_from_queue = AsyncMock() # ty: ignore
app._maybe_drain_deferred = AsyncMock() # ty: ignore
app._set_spinner = AsyncMock() # ty: ignore
app._schedule_git_branch_refresh = MagicMock() # ty: ignore
app._schedule_thread_cache_refresh = refresh_mock # ty: ignore

await app._cleanup_agent_task()

refresh_mock.assert_called_once_with()

async def test_schedule_thread_cache_refresh_noops_during_exit(self) -> None:
"""Shutdown should prevent new background thread-cache refreshes."""
app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")

async with app.run_test() as pilot:
await pilot.pause()
app._exit = True
run_worker_mock = MagicMock()
app.run_worker = run_worker_mock # ty: ignore

app._schedule_thread_cache_refresh()

run_worker_mock.assert_not_called()

async def test_schedule_git_branch_refresh_noops_during_exit(self) -> None:
"""Shutdown should prevent new background git refresh tasks."""
app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
Expand Down Expand Up @@ -2331,6 +2359,19 @@ async def test_prewarm_uses_current_thread_limit(self) -> None:

mock_prewarm.assert_awaited_once_with(limit=7)

async def test_schedule_refresh_runs_prewarm_in_worker(self) -> None:
"""Scheduling a refresh should re-run the prewarm off the event loop."""
app = DeepAgentsApp()

async with app.run_test() as pilot:
await pilot.pause()
prewarm_mock = AsyncMock()
app._prewarm_threads_cache = prewarm_mock # ty: ignore
app._schedule_thread_cache_refresh()
await pilot.pause()

prewarm_mock.assert_awaited_once_with()

async def test_show_thread_selector_uses_cached_rows(self) -> None:
"""Thread selector should receive prefetched rows when available."""
cached_threads = [
Expand Down