mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-21 17:25:26 -04:00
perf(code): background refresh for @ file completion cache (#3911)
Closes #1409 `@` file-mention completion now refreshes in the background so newly created or deleted files appear without restarting or switching directories. --- `@` file-mention completion already pre-warms its index on mount and re-warms on cwd switches, with the results cached for the session. The remaining gap was freshness: files created or deleted mid-session stayed stale until the next cwd switch. This adds a periodic background refresh of the file-completion cache (every 30s). `FuzzyFileController.warm_cache` gains a keyword-only `force` flag so a refresh re-walks the project even when the cache is already populated. The walk runs off the event loop in a worker thread and atomically swaps in the new list — the existing cache stays visible until the fresh one is ready, so typing is never blocked. The existing generation guard still drops results from a refresh that a cwd switch has superseded. Made by [Open SWE](https://openswe.vercel.app) --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
@@ -7,7 +7,6 @@ for slash commands (/) and file mentions (@).
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import shutil
|
||||
|
||||
@@ -618,7 +617,7 @@ class FuzzyFileController:
|
||||
self._file_cache = None
|
||||
self.reset()
|
||||
|
||||
async def warm_cache(self) -> None:
|
||||
async def warm_cache(self, *, force: bool = False) -> None:
|
||||
"""Pre-populate the file cache off the event loop.
|
||||
|
||||
Also resolves a project root deferred by `set_cwd`, so the blocking
|
||||
@@ -631,6 +630,12 @@ class FuzzyFileController:
|
||||
belonging to a newer generation. (Snapshotting again before the second
|
||||
await would defeat the guard: it would match the post-supersession
|
||||
generation and let a stale-root file walk win.)
|
||||
|
||||
Args:
|
||||
force: Re-walk and swap in a fresh file list even when the cache is
|
||||
already populated. Used by the periodic background refresh so
|
||||
files created or deleted mid-session surface in `@` completion.
|
||||
The existing cache stays visible until the new walk completes.
|
||||
"""
|
||||
cwd = self._cwd
|
||||
generation = self._cache_generation
|
||||
@@ -646,14 +651,20 @@ class FuzzyFileController:
|
||||
self._file_cache = None
|
||||
self._project_root = resolved
|
||||
self._project_root_pending = False
|
||||
if self._file_cache is not None:
|
||||
if not force and self._file_cache is not None:
|
||||
return
|
||||
project_root = self._project_root
|
||||
# Best-effort; _get_files() falls back to sync on failure.
|
||||
with contextlib.suppress(Exception):
|
||||
# Best-effort: on failure the existing cache (if any) stays in place. A
|
||||
# cold cache (`_file_cache is None`) is later filled synchronously by
|
||||
# `_get_files()`; a force refresh that fails simply leaves the prior
|
||||
# list visible. Log at debug so a recurring background refresh failure
|
||||
# (the 30s timer) is diagnosable rather than silently stale.
|
||||
try:
|
||||
files = await asyncio.to_thread(_get_project_files, project_root)
|
||||
if generation == self._cache_generation:
|
||||
self._file_cache = _scope_files_to_cwd(files, project_root, cwd)
|
||||
except Exception: # best-effort refresh; prior cache is the fallback
|
||||
logger.debug("File-cache warm failed for %s", project_root, exc_info=True)
|
||||
|
||||
@staticmethod
|
||||
def can_handle(text: str, cursor_index: int) -> bool:
|
||||
|
||||
@@ -91,6 +91,9 @@ Keeps multi-line pastes grouped as one input even when newlines arrive as
|
||||
terminal read boundaries), instead of submitting mid-paste.
|
||||
"""
|
||||
|
||||
_FILE_CACHE_WORKER_GROUP = "file-cache"
|
||||
"""Textual worker group for all `@` file-completion cache warmers."""
|
||||
|
||||
_BACKSLASH_ENTER_GAP_SECONDS = 0.15
|
||||
"""Maximum gap between a `\\` key and a following `enter` key to treat the
|
||||
pair as a terminal-emitted shift+enter sequence.
|
||||
@@ -118,6 +121,14 @@ comfortably covers the FocusIn-to-mouse-report latency while staying below a
|
||||
deliberate click-pause-click interaction.
|
||||
"""
|
||||
|
||||
_FILE_CACHE_REFRESH_INTERVAL_SECONDS = 30.0
|
||||
"""How often to refresh the `@` file-completion cache in the background.
|
||||
|
||||
The cache is pre-warmed on mount and re-warmed on cwd switches, but files
|
||||
created or deleted mid-session would otherwise stay stale until the next switch.
|
||||
A periodic refresh keeps `@` suggestions current; the walk runs off the event
|
||||
loop and swaps in atomically, so it never blocks typing."""
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from textual import events
|
||||
from textual.app import ComposeResult
|
||||
@@ -1646,13 +1657,42 @@ class ChatInput(Vertical):
|
||||
|
||||
self._rebuild_argument_hints(SLASH_COMMANDS)
|
||||
|
||||
self.run_worker(
|
||||
self._file_controller.warm_cache(),
|
||||
exclusive=False,
|
||||
exit_on_error=False,
|
||||
self._warm_file_cache()
|
||||
self.set_interval(
|
||||
_FILE_CACHE_REFRESH_INTERVAL_SECONDS,
|
||||
self._refresh_file_cache,
|
||||
)
|
||||
self._text_area.focus()
|
||||
|
||||
def _warm_file_cache(self, *, force: bool = False, exclusive: bool = False) -> None:
|
||||
"""Schedule an `@` file-completion cache warmer.
|
||||
|
||||
No-ops before `on_mount` wires up the file controller (the periodic
|
||||
refresh interval can fire during teardown or a partial mount).
|
||||
|
||||
Args:
|
||||
force: Re-walk even when the cache is already populated. The prior
|
||||
cache stays visible until the new walk completes.
|
||||
exclusive: Cancel any other in-flight warmer in the shared worker
|
||||
group before starting, so a slow walk is superseded by the next
|
||||
tick rather than stacking overlapping walks. Used by the
|
||||
periodic refresh; the on-mount/cwd-switch warmers run
|
||||
non-exclusively so a quick invalidation can warm concurrently.
|
||||
"""
|
||||
file_controller = getattr(self, "_file_controller", None)
|
||||
if file_controller is None:
|
||||
return
|
||||
self.run_worker(
|
||||
file_controller.warm_cache(force=force),
|
||||
exclusive=exclusive,
|
||||
group=_FILE_CACHE_WORKER_GROUP,
|
||||
exit_on_error=False,
|
||||
)
|
||||
|
||||
def _refresh_file_cache(self) -> None:
|
||||
"""Re-warm the `@` file-completion cache off the event loop."""
|
||||
self._warm_file_cache(force=True, exclusive=True)
|
||||
|
||||
def set_cwd(self, cwd: str | Path) -> None:
|
||||
"""Update file completion to use a new cwd.
|
||||
|
||||
@@ -1663,11 +1703,7 @@ class ChatInput(Vertical):
|
||||
file_controller = getattr(self, "_file_controller", None)
|
||||
if file_controller is not None:
|
||||
file_controller.set_cwd(self._cwd)
|
||||
self.run_worker(
|
||||
file_controller.warm_cache(),
|
||||
exclusive=False,
|
||||
exit_on_error=False,
|
||||
)
|
||||
self._warm_file_cache()
|
||||
|
||||
def update_slash_commands(self, commands: list[CommandEntry]) -> None:
|
||||
"""Update the slash command controller's command list.
|
||||
|
||||
@@ -790,6 +790,69 @@ class TestFuzzyFileControllerWarmCacheRace:
|
||||
assert controller._project_root_pending is False
|
||||
assert controller._file_cache == ["a/new.py"]
|
||||
|
||||
async def test_force_warmer_does_not_overwrite_newer_cwd(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A force refresh superseded mid-walk must not clobber a newer cwd.
|
||||
|
||||
`force=True` is a new way to reach the file-walk-and-swap block on an
|
||||
already-populated cache (a populated cache otherwise short-circuits), so
|
||||
a background refresh racing a `set_cwd` is a genuinely new scenario.
|
||||
"""
|
||||
sub_a = tmp_path / "a"
|
||||
sub_a.mkdir()
|
||||
sub_b = tmp_path / "b"
|
||||
sub_b.mkdir()
|
||||
|
||||
entered_force = threading.Event()
|
||||
release_force = threading.Event()
|
||||
lock = threading.Lock()
|
||||
a_calls = 0
|
||||
|
||||
def fake_files(root: Path) -> list[str]:
|
||||
nonlocal a_calls
|
||||
|
||||
if root == sub_a:
|
||||
with lock:
|
||||
a_calls += 1
|
||||
call = a_calls
|
||||
if call == 1:
|
||||
# Initial warm populates the cache and returns immediately.
|
||||
return ["a/file.py"]
|
||||
# The forced background refresh; block it mid-walk so a newer
|
||||
# cwd switch can supersede it.
|
||||
entered_force.set()
|
||||
release_force.wait(timeout=5)
|
||||
return ["a/stale.py"]
|
||||
return ["b/file.py"]
|
||||
|
||||
monkeypatch.setattr(autocomplete_module, "find_project_root", lambda _: None)
|
||||
monkeypatch.setattr(autocomplete_module, "_get_project_files", fake_files)
|
||||
|
||||
controller = FuzzyFileController(MagicMock(), cwd=tmp_path)
|
||||
controller.set_cwd(sub_a)
|
||||
await controller.warm_cache()
|
||||
assert controller._file_cache == ["a/file.py"]
|
||||
|
||||
force_task = asyncio.create_task(controller.warm_cache(force=True))
|
||||
await asyncio.to_thread(entered_force.wait, 5)
|
||||
|
||||
# The prior cache stays visible while the forced walk is in flight.
|
||||
assert controller._file_cache == ["a/file.py"]
|
||||
|
||||
# A newer cwd switch supersedes the in-flight forced refresh.
|
||||
controller.set_cwd(sub_b)
|
||||
await controller.warm_cache()
|
||||
assert controller._file_cache == ["b/file.py"]
|
||||
|
||||
release_force.set()
|
||||
await force_task
|
||||
|
||||
# The stale forced walk finished last but dropped its result.
|
||||
assert controller._project_root == sub_b
|
||||
assert controller._project_root_pending is False
|
||||
assert controller._file_cache == ["b/file.py"]
|
||||
|
||||
|
||||
class TestGetProjectFiles:
|
||||
"""Tests for _get_project_files."""
|
||||
@@ -1081,6 +1144,29 @@ class TestFuzzyFileControllerScope:
|
||||
|
||||
assert controller._file_cache == ["main.py"]
|
||||
|
||||
async def test_warm_cache_force_refreshes_populated_cache(
|
||||
self, mock_view, monkeypatch, tmp_path
|
||||
):
|
||||
"""warm_cache(force=True) re-walks and swaps in a fresh list."""
|
||||
project_root = tmp_path
|
||||
(project_root / ".git").mkdir()
|
||||
|
||||
files = ["main.py"]
|
||||
monkeypatch.setattr(
|
||||
autocomplete_module, "_get_project_files", lambda _root: list(files)
|
||||
)
|
||||
|
||||
controller = FuzzyFileController(mock_view, cwd=project_root)
|
||||
await controller.warm_cache()
|
||||
assert controller._file_cache == ["main.py"]
|
||||
|
||||
files.append("added.py")
|
||||
await controller.warm_cache()
|
||||
assert controller._file_cache == ["main.py"]
|
||||
|
||||
await controller.warm_cache(force=True)
|
||||
assert controller._file_cache == ["main.py", "added.py"]
|
||||
|
||||
def test_excludes_sibling_with_shared_prefix(
|
||||
self, mock_view, monkeypatch, tmp_path
|
||||
):
|
||||
|
||||
@@ -27,6 +27,7 @@ from deepagents_code.widgets.chat_input import (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Coroutine
|
||||
from pathlib import Path
|
||||
|
||||
from textual.pilot import Pilot
|
||||
@@ -228,6 +229,122 @@ class _RecordingApp(App[None]):
|
||||
self.submitted.append(event)
|
||||
|
||||
|
||||
async def _noop() -> None:
|
||||
pass
|
||||
|
||||
|
||||
class _RefreshController:
|
||||
def __init__(self) -> None:
|
||||
self.cwd_values: list[Path] = []
|
||||
self.force_values: list[bool] = []
|
||||
|
||||
def set_cwd(self, cwd: Path) -> None:
|
||||
self.cwd_values.append(cwd)
|
||||
|
||||
def warm_cache(self, *, force: bool = False) -> Coroutine[object, object, None]:
|
||||
self.force_values.append(force)
|
||||
return _noop()
|
||||
|
||||
|
||||
class TestChatInputFileCacheRefresh:
|
||||
def test_refresh_file_cache_uses_exclusive_worker(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
chat = ChatInput()
|
||||
controller = _RefreshController()
|
||||
worker_calls: list[dict[str, object]] = []
|
||||
|
||||
def fake_run_worker(
|
||||
work: Coroutine[object, object, None], **kwargs: object
|
||||
) -> None:
|
||||
work.close()
|
||||
worker_calls.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(chat, "_file_controller", controller, raising=False)
|
||||
monkeypatch.setattr(chat, "run_worker", fake_run_worker)
|
||||
|
||||
chat._refresh_file_cache()
|
||||
|
||||
assert controller.force_values == [True]
|
||||
assert worker_calls == [
|
||||
{
|
||||
"exclusive": True,
|
||||
"group": chat_input_module._FILE_CACHE_WORKER_GROUP,
|
||||
"exit_on_error": False,
|
||||
}
|
||||
]
|
||||
|
||||
def test_set_cwd_uses_file_cache_worker_group(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
chat = ChatInput()
|
||||
controller = _RefreshController()
|
||||
worker_calls: list[dict[str, object]] = []
|
||||
|
||||
def fake_run_worker(
|
||||
work: Coroutine[object, object, None], **kwargs: object
|
||||
) -> None:
|
||||
work.close()
|
||||
worker_calls.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(chat, "_file_controller", controller, raising=False)
|
||||
monkeypatch.setattr(chat, "run_worker", fake_run_worker)
|
||||
|
||||
chat.set_cwd(tmp_path)
|
||||
|
||||
assert controller.cwd_values == [tmp_path]
|
||||
assert controller.force_values == [False]
|
||||
assert worker_calls == [
|
||||
{
|
||||
"exclusive": False,
|
||||
"group": chat_input_module._FILE_CACHE_WORKER_GROUP,
|
||||
"exit_on_error": False,
|
||||
}
|
||||
]
|
||||
|
||||
def test_refresh_file_cache_noop_without_controller(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Refresh is a no-op before the file controller is wired up."""
|
||||
chat = ChatInput()
|
||||
worker_calls: list[object] = []
|
||||
monkeypatch.setattr(
|
||||
chat,
|
||||
"run_worker",
|
||||
lambda *args, **kwargs: worker_calls.append((args, kwargs)),
|
||||
)
|
||||
|
||||
assert getattr(chat, "_file_controller", None) is None
|
||||
chat._refresh_file_cache()
|
||||
|
||||
assert worker_calls == []
|
||||
|
||||
async def test_on_mount_schedules_file_cache_refresh(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""`on_mount` schedules the periodic `@` file-cache refresh.
|
||||
|
||||
Without this the cache would only warm on mount and cwd switches, so
|
||||
files created or deleted mid-session would never surface.
|
||||
"""
|
||||
interval_calls: list[tuple[float, object]] = []
|
||||
|
||||
def fake_set_interval(
|
||||
_self: ChatInput, interval: float, callback: object, **_kwargs: object
|
||||
) -> None:
|
||||
interval_calls.append((interval, callback))
|
||||
|
||||
monkeypatch.setattr(ChatInput, "set_interval", fake_set_interval)
|
||||
|
||||
app = _ChatInputTestApp()
|
||||
async with app.run_test():
|
||||
chat = app.query_one(ChatInput)
|
||||
assert (
|
||||
chat_input_module._FILE_CACHE_REFRESH_INTERVAL_SECONDS,
|
||||
chat._refresh_file_cache,
|
||||
) in interval_calls
|
||||
|
||||
|
||||
class TestChatInputScrollbar:
|
||||
"""Regression tests for the chat input's vertical scrollbar behavior.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user