From af30592665724721477b96b382f5d8eaf61978d1 Mon Sep 17 00:00:00 2001 From: Mason Daugherty <61371264+mdrxy@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:12:25 +0000 Subject: [PATCH 1/6] fix(code): keep chat input responsive during `/reload` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run the long reload continuation outside Textual’s App message pump so keystrokes render immediately while configuration reloads. Co-authored-by: open-swe[bot] --- libs/code/deepagents_code/app.py | 33 ++++++++++++----- libs/code/tests/unit_tests/test_reload.py | 44 +++++++++++++++++++++++ 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/libs/code/deepagents_code/app.py b/libs/code/deepagents_code/app.py index 9a1d91ae06..e5b83117dc 100644 --- a/libs/code/deepagents_code/app.py +++ b/libs/code/deepagents_code/app.py @@ -9422,7 +9422,13 @@ async def _process_message(self, value: str, mode: InputMode) -> None: self._strip_mode_value(value, "!", "!!", mode), ) elif mode == "command": - await self._handle_command(value) + if value.lower().strip() == "/reload": + self._schedule_off_message_pump( + self._run_reload_continuation(value), + context="reload", + ) + else: + await self._handle_command(value) elif mode == "normal": await self._handle_user_message(value) else: @@ -9440,6 +9446,16 @@ async def _process_message(self, value: str, mode: InputMode) -> None: ), ) + async def _run_reload_continuation(self, command: str) -> None: + """Run `/reload` without blocking the Textual message pump.""" + try: + await self._handle_command(command) + except Exception: + logger.exception("Detached /reload command failed unexpectedly") + await self._mount_message( + ErrorMessage("Reload failed unexpectedly. Check the debug log."), + ) + @staticmethod def _strip_mode_value( value: str, @@ -24061,18 +24077,17 @@ def _schedule_restart_offer( def _schedule_off_message_pump( self, coro: Coroutine[Any, Any, None], *, context: str ) -> asyncio.Task[None] | None: - """Run a slash-command continuation that opens a modal off the pump. + """Run a slash-command continuation outside the App message pump. Slash commands are dispatched from `on_chat_input_submitted`, which is - awaited inline on the App message pump. Awaiting a confirmation modal - there blocks the pump, so the modal never receives the Enter/Esc key - events it needs to resolve and appears frozen until its watchdog fires. - Detaching the continuation lets the command handler return so the pump - can route keys to the modal — the same reason the post-install restart + awaited inline on the App message pump. Awaiting a confirmation modal or + a long-running command there blocks the pump, so the chat input and modal + key handling freeze until the command returns. Detaching the continuation + lets the pump keep routing keys — the same reason the post-install restart offer is scheduled rather than awaited (see `_schedule_restart_offer`). - Because the command handler now returns while the modal is still open, - the continuation participates in the app's busy state until it ends. + Because the command handler returns while the continuation is still + active, it participates in the app's busy state until it ends. Only one continuation is allowed globally, so differently keyed install, update, and goal commands cannot show overlapping modals or mutate shared state concurrently. diff --git a/libs/code/tests/unit_tests/test_reload.py b/libs/code/tests/unit_tests/test_reload.py index 46f370f904..d0ba5e6a46 100644 --- a/libs/code/tests/unit_tests/test_reload.py +++ b/libs/code/tests/unit_tests/test_reload.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import logging import os import threading @@ -912,6 +913,49 @@ def test_reload_in_slash_commands(self) -> None: assert any(entry.name == "/reload" for entry in get_slash_commands()) +class TestReloadInputResponsiveness: + """`/reload` should not block the Textual message pump.""" + + @pytest.mark.timeout(15) + async def test_keeps_chat_input_responsive( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Typing should render while the reload continuation is still running.""" + from deepagents_code.app import DeepAgentsApp + + app = DeepAgentsApp(agent=MagicMock()) + async with app.run_test() as pilot: + await pilot.pause() + chat_input = app._chat_input + assert chat_input is not None + chat_input.focus_input() + await pilot.pause() + + started = asyncio.Event() + release = asyncio.Event() + + async def _blocked_reload(_command: str) -> None: + started.set() + await release.wait() + + monkeypatch.setattr(app, "_handle_command", _blocked_reload) + + chat_input.mode = "command" + chat_input._submit_value("reload") + await started.wait() + + await pilot.press("h", "i") + await pilot.pause() + typed = chat_input.value + + release.set() + task = app._modal_command_tasks.get("reload") + if task is not None: + await task + + assert typed == "hi" + + class TestReloadModelProfileHints: """`/reload` should refresh profile-derived command hints.""" From e1346934182e3a271cf0b7bbd4fcf6cf9680c27d Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Mon, 17 Aug 2026 13:19:45 -0400 Subject: [PATCH 2/6] fix(code): detach `/reload` from the handler and preserve queued submissions Move the reload detachment out of the `_process_message` dispatcher and into the `/reload` branch of `_handle_command`, matching the pattern every other `_schedule_off_message_pump` consumer uses (detach from inside the handler, not from the router). `/reload` opens no modal, so use a dedicated tracked task via `_schedule_reload` rather than the single global modal-continuation slot, whose "answer the pending prompt first" rejection would be a lie here. Preserve messages queued while a detached reload restarts an idle server: snapshot them before the restart's `_discard_queue()`, restore after, and drain once the reload finishes, so typing submitted mid-reload is no longer silently dropped. --- libs/code/deepagents_code/app.py | 505 ++++++++++++---------- libs/code/tests/unit_tests/test_app.py | 83 ++++ libs/code/tests/unit_tests/test_reload.py | 26 +- 3 files changed, 379 insertions(+), 235 deletions(-) diff --git a/libs/code/deepagents_code/app.py b/libs/code/deepagents_code/app.py index db939c86cd..c4e6b76356 100644 --- a/libs/code/deepagents_code/app.py +++ b/libs/code/deepagents_code/app.py @@ -4200,6 +4200,13 @@ def __init__( every invocation. """ + self._reload_task: asyncio.Task[None] | None = None + """The in-flight detached `/reload` task, if any (see `_schedule_reload`). + + Tests and shutdown await it; production callers fire-and-forget via the + `_log_task_exception` done callback. + """ + self._plugin_fingerprints: dict[str, _PluginFingerprint] | None = None """Rolling plugin-fingerprint baseline keyed by plugin id. @@ -9459,13 +9466,7 @@ async def _process_message(self, value: str, mode: InputMode) -> None: self._strip_mode_value(value, "!", "!!", mode), ) elif mode == "command": - if value.lower().strip() == "/reload": - self._schedule_off_message_pump( - self._run_reload_continuation(value), - context="reload", - ) - else: - await self._handle_command(value) + await self._handle_command(value) elif mode == "normal": await self._handle_user_message(value) else: @@ -9483,16 +9484,6 @@ async def _process_message(self, value: str, mode: InputMode) -> None: ), ) - async def _run_reload_continuation(self, command: str) -> None: - """Run `/reload` without blocking the Textual message pump.""" - try: - await self._handle_command(command) - except Exception: - logger.exception("Detached /reload command failed unexpectedly") - await self._mount_message( - ErrorMessage("Reload failed unexpectedly. Check the debug log."), - ) - @staticmethod def _strip_mode_value( value: str, @@ -14464,210 +14455,7 @@ async def _handle_command(self, command: str) -> None: await self._show_model_selector(extra_kwargs=extra_kwargs) elif cmd == "/reload": await self._mount_message(UserMessage(command)) - - # Snapshot pre-reload skill names so the report can show diff. - old_skill_names = {s["name"] for s in self._discovered_skills} - - try: - changes = settings.reload_from_environment() - - from deepagents_code.model_config import clear_caches - - clear_caches() - self._sync_status_model() - except (OSError, ValueError): - logger.exception("Failed to reload configuration") - await self._mount_message( - AppMessage( - "Failed to reload configuration. Check your .env " - "file and environment variables for syntax errors, " - "then try again.", - ), - ) - return - - # Reload user themes from config.toml and re-register with Textual - theme_reload_ok = True - try: - theme.reload_registry() - self._register_custom_themes() - except Exception: - theme_reload_ok = False - logger.warning("Failed to reload user themes", exc_info=True) - - # Re-resolve and apply the theme preference so a per-terminal or - # global default saved by another session is picked up. This - # re-syncs to on-disk config using the same resolution as startup - # (env -> [ui.terminal_themes][TERM_PROGRAM] -> [ui].theme -> - # default), which intentionally overrides an unsaved in-session - # `/theme` choice. Guarded on the registry reload succeeding since - # the target theme must be registered before it can be applied. - theme_switched_to: str | None = None - if theme_reload_ok: - try: - new_theme = _load_theme_preference() - if new_theme != self.theme and new_theme in theme.get_registry(): - self.theme = new_theme - self.sync_terminal_background() - self.refresh_css(animate=False) - theme_switched_to = new_theme - except Exception: - logger.warning( - "Failed to re-apply theme preference on reload", - exc_info=True, - ) - - # Re-discover skills so autocomplete reflects any new/removed - # skills. Run via the same exclusive-group worker used at - # startup so any in-flight startup discovery is cancelled - # rather than racing this one, then await its completion so - # the report can include the diff. - skill_worker = self.run_worker( - self._discover_skills(), - exclusive=True, - group="startup-skill-discovery", - ) - await skill_worker.wait() - discovery_ok = skill_worker.result is True - new_skill_names = {s["name"] for s in self._discovered_skills} - added_skills = sorted(new_skill_names - old_skill_names) - removed_skills = sorted(old_skill_names - new_skill_names) - - if changes: - report = "Configuration reloaded. Changes:\n" + "\n".join( - f" - {change}" for change in changes - ) - else: - report = "Configuration reloaded. No changes detected." - report += "\nModel config caches cleared." - if theme_reload_ok: - report += "\nTheme registry reloaded." - if theme_switched_to is not None: - entry = theme.get_registry().get(theme_switched_to) - label = entry.label if entry is not None else theme_switched_to - report += f"\nSwitched theme to {label}." - else: - report += ( - "\nTheme registry reload failed. Check config.toml for errors." - ) - if not discovery_ok: - # Diff is meaningless when discovery failed: prior cache - # was preserved, so old vs. new is identical and - # `Skills reloaded. No changes detected.` would be a lie. - report += ( - "\nSkill re-discovery failed; existing /skill: list left as-is." - ) - elif added_skills or removed_skills: - skill_lines = [] - if added_skills: - skill_lines.append(f" - Added: {', '.join(added_skills)}") - if removed_skills: - skill_lines.append(f" - Removed: {', '.join(removed_skills)}") - report += "\nSkills updated:\n" + "\n".join(skill_lines) - - # Rediscover plugins and restart the owned server so plugin MCP config - # is picked up without a separate slash command. - from deepagents_code.plugins.adapters.hooks import plugin_hook_event_names - from deepagents_code.plugins.adapters.mcp import plugin_mcp_configs - - try: - plugin_result, new_plugin_fingerprints = await asyncio.to_thread( - self._discover_plugins_with_fingerprints - ) - except Exception: - # User and project hooks still reload when plugin discovery fails. - await self._reload_hooks(plugins=()) - logger.exception("Failed to discover plugins during /reload") - report += "\nCouldn't read plugin state; run /reload to be safe." - else: - # Server-owned events are fixed when the server starts, so refresh - # hooks from this same plugin snapshot before any restart. - plugins = plugin_result.plugins - await self._reload_hooks(plugins=plugins) - old_plugin_fingerprints = self._plugin_fingerprints - self._plugin_fingerprints = new_plugin_fingerprints - discovered_plugin_ids = frozenset( - plugin.plugin_id for plugin in plugins - ) - plugin_count = len(plugins) - mcp_configs = plugin_mcp_configs(plugins) - mcp_count = sum( - len(servers) - for config in mcp_configs - if isinstance((servers := config.get("mcpServers")), dict) - ) - plugin_skill_count = sum(1 for name in new_skill_names if ":" in name) - hook_count = sum(map(len, map(plugin_hook_event_names, plugins))) - report += ( - f"\nPlugins: {plugin_count} plugin" - f"{'s' if plugin_count != 1 else ''} · " - f"{plugin_skill_count} skill" - f"{'s' if plugin_skill_count != 1 else ''} · " - f"{mcp_count} plugin MCP server" - f"{'s' if mcp_count != 1 else ''} · " - f"{hook_count} hook{'s' if hook_count != 1 else ''}" - ) - if old_plugin_fingerprints is not None: - old_ids = set(old_plugin_fingerprints) - new_ids = set(new_plugin_fingerprints) - added_count = len(new_ids - old_ids) - removed_count = len(old_ids - new_ids) - changed_count = sum( - self._plugin_fingerprint_changed( - old_plugin_fingerprints[plugin_id], - new_plugin_fingerprints[plugin_id], - ) - for plugin_id in old_ids & new_ids - ) - change_parts = [] - for count, label in ( - (added_count, "added"), - (removed_count, "removed"), - (changed_count, "changed"), - ): - if count: - noun = "plugin" if count == 1 else "plugins" - change_parts.append(f"{count} {noun} {label}") - if change_parts: - report += "\nPlugin changes: " + ", ".join(change_parts) + "." - else: - report += "\nPlugin changes: no changes detected." - # Reads each added plugin's MCP config from disk; keep it - # off the UI thread like the discovery scan above. - login_labels = await asyncio.to_thread( - self._plugin_login_labels, - plugins, - new_ids - old_ids, - ) - for label in login_labels: - report += f"\nSign in to {label} via `/mcp`." - if plugin_result.warnings: - report += ( - f"\n{len(plugin_result.warnings)} plugin warning(s) " - "during load." - ) - - restarted = False - if self._server_proc is not None and self._server_kwargs is not None: - if self._agent_running and self._agent_worker: - self._cancel_worker(self._agent_worker) - # Via `_set_agent_running` so the quiescence event is - # released with the flag; a bare assignment leaves - # `_agent_quiescent` cleared. - self._set_agent_running(False) - else: - self._discard_queue() - restarted = await self._restart_server_manual() - if restarted: - self._session_plugin_ids = discovered_plugin_ids - report += "\nAgent server restarted for plugin MCP." - else: - report += ( - "\nAgent server was not restarted; plugin MCP may be stale." - ) - - await self._mount_message(AppMessage(report)) - await self._maybe_start_deferred_server_from_default() + self._schedule_reload() elif cmd.startswith("/skill:"): await self._handle_skill_command(command) # -- Debug commands (not in COMMANDS / autocomplete) ------------------ @@ -14977,6 +14765,266 @@ def _retry() -> str | None: ) return content + def _schedule_reload(self) -> asyncio.Task[None]: + """Run `/reload` off the Textual message pump. + + The reload body awaits workers and `asyncio.to_thread` calls (config, + theme, skill, plugin, and hook refreshes, plus a possible server + restart), so awaiting it inline in `_handle_command` blocks key events + from reaching the chat input for its whole duration. Detaching lets + the pump keep routing keys; submissions made mid-reload queue as usual + once the app goes busy for the server restart. + + `_schedule_off_message_pump` is deliberately not used: its single + global slot is for modal-opening continuations, `/reload` opens no + modal, and the "answer the pending prompt first" rejection that slot + can produce would be a lie here. + + Returns: + The reload task, so tests can await completion. + """ + task = asyncio.create_task(self._run_reload(), name="reload") + task.add_done_callback(_log_task_exception) + self._reload_task = task + return task + + async def _run_reload(self) -> None: + """Refresh config, themes, skills, plugins, and hooks, then report. + + Runs detached from the message pump (scheduled by `_schedule_reload`), + so it mounts its own failure message — the `_handle_command` + `try/except` no longer wraps it. + """ + from deepagents_code.config import settings + + try: + # Snapshot pre-reload skill names so the report can show diff. + old_skill_names = {s["name"] for s in self._discovered_skills} + + try: + changes = settings.reload_from_environment() + + from deepagents_code.model_config import clear_caches + + clear_caches() + self._sync_status_model() + except (OSError, ValueError): + logger.exception("Failed to reload configuration") + await self._mount_message( + AppMessage( + "Failed to reload configuration. Check your .env " + "file and environment variables for syntax errors, " + "then try again.", + ), + ) + return + + # Reload user themes from config.toml and re-register with Textual + theme_reload_ok = True + try: + theme.reload_registry() + self._register_custom_themes() + except Exception: + theme_reload_ok = False + logger.warning("Failed to reload user themes", exc_info=True) + + # Re-resolve and apply the theme preference so a per-terminal or + # global default saved by another session is picked up. This + # re-syncs to on-disk config using the same resolution as startup + # (env -> [ui.terminal_themes][TERM_PROGRAM] -> [ui].theme -> + # default), which intentionally overrides an unsaved in-session + # `/theme` choice. Guarded on the registry reload succeeding since + # the target theme must be registered before it can be applied. + theme_switched_to: str | None = None + if theme_reload_ok: + try: + new_theme = _load_theme_preference() + if new_theme != self.theme and new_theme in theme.get_registry(): + self.theme = new_theme + self.sync_terminal_background() + self.refresh_css(animate=False) + theme_switched_to = new_theme + except Exception: + logger.warning( + "Failed to re-apply theme preference on reload", + exc_info=True, + ) + + # Re-discover skills so autocomplete reflects any new/removed + # skills. Run via the same exclusive-group worker used at + # startup so any in-flight startup discovery is cancelled + # rather than racing this one, then await its completion so + # the report can include the diff. + skill_worker = self.run_worker( + self._discover_skills(), + exclusive=True, + group="startup-skill-discovery", + ) + await skill_worker.wait() + discovery_ok = skill_worker.result is True + new_skill_names = {s["name"] for s in self._discovered_skills} + added_skills = sorted(new_skill_names - old_skill_names) + removed_skills = sorted(old_skill_names - new_skill_names) + + if changes: + report = "Configuration reloaded. Changes:\n" + "\n".join( + f" - {change}" for change in changes + ) + else: + report = "Configuration reloaded. No changes detected." + report += "\nModel config caches cleared." + if theme_reload_ok: + report += "\nTheme registry reloaded." + if theme_switched_to is not None: + entry = theme.get_registry().get(theme_switched_to) + label = entry.label if entry is not None else theme_switched_to + report += f"\nSwitched theme to {label}." + else: + report += ( + "\nTheme registry reload failed. Check config.toml for errors." + ) + if not discovery_ok: + # Diff is meaningless when discovery failed: prior cache + # was preserved, so old vs. new is identical and + # `Skills reloaded. No changes detected.` would be a lie. + report += ( + "\nSkill re-discovery failed; existing /skill: list left as-is." + ) + elif added_skills or removed_skills: + skill_lines = [] + if added_skills: + skill_lines.append(f" - Added: {', '.join(added_skills)}") + if removed_skills: + skill_lines.append(f" - Removed: {', '.join(removed_skills)}") + report += "\nSkills updated:\n" + "\n".join(skill_lines) + + # Rediscover plugins and restart the owned server so plugin MCP config + # is picked up without a separate slash command. + from deepagents_code.plugins.adapters.hooks import plugin_hook_event_names + from deepagents_code.plugins.adapters.mcp import plugin_mcp_configs + + try: + plugin_result, new_plugin_fingerprints = await asyncio.to_thread( + self._discover_plugins_with_fingerprints + ) + except Exception: + # User and project hooks still reload when plugin discovery fails. + await self._reload_hooks(plugins=()) + logger.exception("Failed to discover plugins during /reload") + report += "\nCouldn't read plugin state; run /reload to be safe." + else: + # Server-owned events are fixed when the server starts, so refresh + # hooks from this same plugin snapshot before any restart. + plugins = plugin_result.plugins + await self._reload_hooks(plugins=plugins) + old_plugin_fingerprints = self._plugin_fingerprints + self._plugin_fingerprints = new_plugin_fingerprints + discovered_plugin_ids = frozenset( + plugin.plugin_id for plugin in plugins + ) + plugin_count = len(plugins) + mcp_configs = plugin_mcp_configs(plugins) + mcp_count = sum( + len(servers) + for config in mcp_configs + if isinstance((servers := config.get("mcpServers")), dict) + ) + plugin_skill_count = sum(1 for name in new_skill_names if ":" in name) + hook_count = sum(map(len, map(plugin_hook_event_names, plugins))) + report += ( + f"\nPlugins: {plugin_count} plugin" + f"{'s' if plugin_count != 1 else ''} · " + f"{plugin_skill_count} skill" + f"{'s' if plugin_skill_count != 1 else ''} · " + f"{mcp_count} plugin MCP server" + f"{'s' if mcp_count != 1 else ''} · " + f"{hook_count} hook{'s' if hook_count != 1 else ''}" + ) + if old_plugin_fingerprints is not None: + old_ids = set(old_plugin_fingerprints) + new_ids = set(new_plugin_fingerprints) + added_count = len(new_ids - old_ids) + removed_count = len(old_ids - new_ids) + changed_count = sum( + self._plugin_fingerprint_changed( + old_plugin_fingerprints[plugin_id], + new_plugin_fingerprints[plugin_id], + ) + for plugin_id in old_ids & new_ids + ) + change_parts = [] + for count, label in ( + (added_count, "added"), + (removed_count, "removed"), + (changed_count, "changed"), + ): + if count: + noun = "plugin" if count == 1 else "plugins" + change_parts.append(f"{count} {noun} {label}") + if change_parts: + report += "\nPlugin changes: " + ", ".join(change_parts) + "." + else: + report += "\nPlugin changes: no changes detected." + # Reads each added plugin's MCP config from disk; keep it + # off the UI thread like the discovery scan above. + login_labels = await asyncio.to_thread( + self._plugin_login_labels, + plugins, + new_ids - old_ids, + ) + for label in login_labels: + report += f"\nSign in to {label} via `/mcp`." + if plugin_result.warnings: + report += ( + f"\n{len(plugin_result.warnings)} plugin warning(s) " + "during load." + ) + + restarted = False + if self._server_proc is not None and self._server_kwargs is not None: + if self._agent_running and self._agent_worker: + self._cancel_worker(self._agent_worker) + # Via `_set_agent_running` so the quiescence event is + # released with the flag; a bare assignment leaves + # `_agent_quiescent` cleared. + self._set_agent_running(False) + preserved: list[QueuedMessage] = [] + else: + # `/reload` now runs detached, so the user may have + # submitted messages that queued while the reload was + # busy. The restart's `_discard_queue()` would silently + # drop them; snapshot and restore them instead. Only the + # idle path preserves: the running-agent path above + # cancels a turn and intentionally drops its backlog so + # nothing fires against the respawned agent. + preserved = list(self._pending_messages) + self._discard_queue() + restarted = await self._restart_server_manual() + if preserved: + self._pending_messages.extendleft(reversed(preserved)) + self._sync_status_queued() + if restarted: + self._session_plugin_ids = discovered_plugin_ids + report += "\nAgent server restarted for plugin MCP." + else: + report += ( + "\nAgent server was not restarted; plugin MCP may be stale." + ) + + await self._mount_message(AppMessage(report)) + await self._maybe_start_deferred_server_from_default() + # Process any messages queued during the reload (e.g. submitted while + # the app was busy with the server restart and preserved above). + if self._pending_messages and not self._agent_running: + self.call_after_refresh( + lambda: asyncio.create_task(self._process_next_from_queue()), + ) + except Exception: + logger.exception("Detached /reload failed unexpectedly") + await self._mount_message( + ErrorMessage("Reload failed unexpectedly. Check the debug log."), + ) + async def _handle_skill_command(self, command: str) -> None: """Handle a `/skill:` command by loading and invoking a skill. @@ -24125,17 +24173,18 @@ def _schedule_restart_offer( def _schedule_off_message_pump( self, coro: Coroutine[Any, Any, None], *, context: str ) -> asyncio.Task[None] | None: - """Run a slash-command continuation outside the App message pump. + """Run a slash-command continuation that opens a modal off the pump. Slash commands are dispatched from `on_chat_input_submitted`, which is - awaited inline on the App message pump. Awaiting a confirmation modal or - a long-running command there blocks the pump, so the chat input and modal - key handling freeze until the command returns. Detaching the continuation - lets the pump keep routing keys — the same reason the post-install restart + awaited inline on the App message pump. Awaiting a confirmation modal + there blocks the pump, so the modal never receives the Enter/Esc key + events it needs to resolve and appears frozen until its watchdog fires. + Detaching the continuation lets the command handler return so the pump + can route keys to the modal — the same reason the post-install restart offer is scheduled rather than awaited (see `_schedule_restart_offer`). - Because the command handler returns while the continuation is still - active, it participates in the app's busy state until it ends. + Because the command handler now returns while the modal is still open, + the continuation participates in the app's busy state until it ends. Only one continuation is allowed globally, so differently keyed install, update, and goal commands cannot show overlapping modals or mutate shared state concurrently. diff --git a/libs/code/tests/unit_tests/test_app.py b/libs/code/tests/unit_tests/test_app.py index 045e31fe53..c22ecd685d 100644 --- a/libs/code/tests/unit_tests/test_app.py +++ b/libs/code/tests/unit_tests/test_app.py @@ -33009,11 +33009,94 @@ async def restart_manual() -> bool: caller = asyncio.current_task() await app._handle_command("/reload") + assert app._reload_task is not None + await app._reload_task proc.restart.assert_awaited_once() assert caller not in app._server_restart_tasks assert not app._server_restart_tasks + async def test_reload_preserves_queue_across_idle_server_restart( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Messages submitted during a detached `/reload` survive the restart. + + `/reload` runs off the message pump, so a prompt submitted mid-reload + queues (the app is busy with the server restart). The idle restart path + discards the queue before respawning; without preservation that prompt + would vanish. + """ + from deepagents_code import theme + from deepagents_code.config import settings + + app = DeepAgentsApp(agent=MagicMock()) + async with app.run_test() as pilot: + await pilot.pause() + proc = await self._prepare(app) + app._plugin_fingerprints = {} + + gate = asyncio.Event() + gate_waiting = asyncio.Event() + + async def restart_manual() -> bool: + gate_waiting.set() + # The real respawn marks the app connecting for its duration, + # which is what queues mid-reload submissions; reproduce that + # busy state here since the mocked restart skips it. + app._connecting = True + try: + await gate.wait() + await app._restart_server_process(proc) + finally: + app._connecting = False + return True + + monkeypatch.setattr(settings, "reload_from_environment", list) + monkeypatch.setattr( + "deepagents_code.model_config.clear_caches", lambda: None + ) + monkeypatch.setattr(theme, "reload_registry", lambda: None) + monkeypatch.setattr(app, "_register_custom_themes", lambda: None) + monkeypatch.setattr( + "deepagents_code.app._load_theme_preference", lambda: app.theme + ) + monkeypatch.setattr(app, "_discover_skills", AsyncMock(return_value=True)) + monkeypatch.setattr( + app, + "_discover_plugins_with_fingerprints", + lambda: (SimpleNamespace(plugins=[], warnings=[]), {}), + ) + monkeypatch.setattr(app, "_restart_server_manual", restart_manual) + monkeypatch.setattr( + app, + "_maybe_start_deferred_server_from_default", + AsyncMock(return_value=False), + ) + handled: list[str] = [] + + async def record(text: str) -> None: # noqa: RUF029 + handled.append(text) + + monkeypatch.setattr(app, "_handle_user_message", record) + + await app._handle_command("/reload") + assert app._reload_task is not None + # Wait until the reload reaches the gated restart so the submission + # below queues against a busy app rather than racing the discard. + await gate_waiting.wait() + await app._submit_input("typed during reload", "normal") + assert len(app._pending_messages) == 1 + + gate.set() + await app._reload_task + # The preserved message drains via call_after_refresh; run the + # scheduled callback before asserting on it. + await pilot.pause() + await asyncio.sleep(0) + + assert not app._pending_messages + assert handled == ["typed during reload"] + async def test_pending_mcp_reconnect_does_not_leave_restart_registration( self, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/libs/code/tests/unit_tests/test_reload.py b/libs/code/tests/unit_tests/test_reload.py index d0ba5e6a46..da6ca62382 100644 --- a/libs/code/tests/unit_tests/test_reload.py +++ b/libs/code/tests/unit_tests/test_reload.py @@ -920,7 +920,7 @@ class TestReloadInputResponsiveness: async def test_keeps_chat_input_responsive( self, monkeypatch: pytest.MonkeyPatch ) -> None: - """Typing should render while the reload continuation is still running.""" + """Typing should render while the detached reload task is still running.""" from deepagents_code.app import DeepAgentsApp app = DeepAgentsApp(agent=MagicMock()) @@ -934,14 +934,14 @@ async def test_keeps_chat_input_responsive( started = asyncio.Event() release = asyncio.Event() - async def _blocked_reload(_command: str) -> None: + async def _blocked_reload() -> None: started.set() await release.wait() - monkeypatch.setattr(app, "_handle_command", _blocked_reload) + monkeypatch.setattr(app, "_run_reload", _blocked_reload) chat_input.mode = "command" - chat_input._submit_value("reload") + chat_input._submit_value("/reload") await started.wait() await pilot.press("h", "i") @@ -949,9 +949,7 @@ async def _blocked_reload(_command: str) -> None: typed = chat_input.value release.set() - task = app._modal_command_tasks.get("reload") - if task is not None: - await task + await pilot.pause() assert typed == "hi" @@ -1011,6 +1009,8 @@ async def _fake_discover() -> bool: # noqa: RUF029 write_config("new") await app._handle_command("/reload") + if app._reload_task is not None: + await app._reload_task assert app._chat_input._argument_hint_overrides["effort"] == ( "[new|clear]" @@ -1072,6 +1072,8 @@ async def _fake_discover() -> bool: # noqa: RUF029 # awaited as coroutine by ` monkeypatch.setattr(app, "_discover_skills", _fake_discover) await app._handle_command("/reload") + if app._reload_task is not None: + await app._reload_task await pilot.pause() return "\n".join(str(w._content) for w in app.query(AppMessage)) @@ -1194,6 +1196,8 @@ async def _fake_discover() -> bool: # noqa: RUF029 # awaited by handler ) await app._handle_command("/reload") + if app._reload_task is not None: + await app._reload_task await pilot.pause() text = "\n".join(str(w._content) for w in app.query(AppMessage)) @@ -1898,6 +1902,8 @@ async def _fake_discover() -> bool: # noqa: RUF029 ) await app._handle_command("/reload") + if app._reload_task is not None: + await app._reload_task await pilot.pause() text = "\n".join(str(w._content) for w in app.query(AppMessage)) @@ -1948,6 +1954,8 @@ def fingerprint_plugins( app._plugin_fingerprints = old await app._handle_command("/reload") + if app._reload_task is not None: + await app._reload_task await pilot.pause() return "\n".join(str(w._content) for w in app.query(AppMessage)) @@ -2151,6 +2159,8 @@ async def _fake_discover() -> bool: # noqa: RUF029 ) await app._handle_command("/reload") + if app._reload_task is not None: + await app._reload_task await pilot.pause() text = "\n".join(str(w._content) for w in app.query(AppMessage)) @@ -2209,6 +2219,8 @@ async def _fake_restart() -> bool: # noqa: RUF029 ) await app._handle_command("/reload") + if app._reload_task is not None: + await app._reload_task await pilot.pause() assert app._session_plugin_ids == expected_ids From 86d233b62e21f0609c200946d962dabd0d720564 Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Mon, 17 Aug 2026 13:51:11 -0400 Subject: [PATCH 3/6] fix(code): serialize reload requests --- libs/code/deepagents_code/app.py | 52 +++++++++++++++--- libs/code/tests/unit_tests/test_reload.py | 64 +++++++++++++++++++++++ 2 files changed, 108 insertions(+), 8 deletions(-) diff --git a/libs/code/deepagents_code/app.py b/libs/code/deepagents_code/app.py index c4e6b76356..db77c61337 100644 --- a/libs/code/deepagents_code/app.py +++ b/libs/code/deepagents_code/app.py @@ -4207,6 +4207,15 @@ def __init__( `_log_task_exception` done callback. """ + self._reloading = False + """Whether `/reload` is refreshing mutable runtime state. + + Keeps submitted prompts queued for the entire reload, including its + pre-restart discovery phases. A server restart only protects the final + phase, and allowing a prompt to start before it would let that restart + cancel the prompt's worker. + """ + self._plugin_fingerprints: dict[str, _PluginFingerprint] | None = None """Rolling plugin-fingerprint baseline keyed by plugin id. @@ -10546,6 +10555,13 @@ async def _submit_input( await self._process_message(value, mode) return + # A second `/reload` must reach `_schedule_reload` immediately so it + # can coalesce with the in-flight reload instead of waiting in the + # normal queue and starting another destructive refresh afterward. + if mode == "command" and normalized == "/reload" and self._reloading: + await self._process_message(value, mode) + return + # Prevent message handling while a thread switch is in-flight. if self._thread_switching: self.notify( @@ -10566,6 +10582,7 @@ async def _submit_input( or self._goal_state_mutating or self._shell_running or self._modal_command_running() + or self._reloading or self._connecting or self._startup_sequence_running or self._server_startup_error is not None @@ -14772,8 +14789,8 @@ def _schedule_reload(self) -> asyncio.Task[None]: theme, skill, plugin, and hook refreshes, plus a possible server restart), so awaiting it inline in `_handle_command` blocks key events from reaching the chat input for its whole duration. Detaching lets - the pump keep routing keys; submissions made mid-reload queue as usual - once the app goes busy for the server restart. + the pump keep routing keys while `_reloading` queues submissions for + the entire refresh, before and during any server restart. `_schedule_off_message_pump` is deliberately not used: its single global slot is for modal-opening continuations, `/reload` opens no @@ -14783,11 +14800,34 @@ def _schedule_reload(self) -> asyncio.Task[None]: Returns: The reload task, so tests can await completion. """ + if self._reloading and self._reload_task is not None: + self.notify("Reload already in progress.", severity="information") + return self._reload_task + + # Set the guard before scheduling: `create_task` does not run the + # coroutine inline, so otherwise a prompt or second reload can enter + # in the gap before `_run_reload` starts. + self._reloading = True task = asyncio.create_task(self._run_reload(), name="reload") task.add_done_callback(_log_task_exception) + task.add_done_callback(self._finish_reload) self._reload_task = task return task + def _finish_reload(self, task: asyncio.Task[None]) -> None: + """Release the reload guard and resume prompts queued during reload. + + Args: + task: The completed reload task. + """ + if task is not self._reload_task: + return + self._reloading = False + if self._pending_messages and not self._agent_running: + self.call_after_refresh( + lambda: asyncio.create_task(self._process_next_from_queue()), + ) + async def _run_reload(self) -> None: """Refresh config, themes, skills, plugins, and hooks, then report. @@ -15013,12 +15053,6 @@ async def _run_reload(self) -> None: await self._mount_message(AppMessage(report)) await self._maybe_start_deferred_server_from_default() - # Process any messages queued during the reload (e.g. submitted while - # the app was busy with the server restart and preserved above). - if self._pending_messages and not self._agent_running: - self.call_after_refresh( - lambda: asyncio.create_task(self._process_next_from_queue()), - ) except Exception: logger.exception("Detached /reload failed unexpectedly") await self._mount_message( @@ -16622,6 +16656,7 @@ async def _process_next_from_queue(self) -> None: or not self._pending_messages or self._exit or self._exiting + or self._reloading or self._connecting ): return @@ -16654,6 +16689,7 @@ async def _process_next_from_queue(self) -> None: or self._goal_state_mutating or self._shell_running or self._modal_command_running() + or self._reloading ) if not busy and self._pending_messages: await self._process_next_from_queue() diff --git a/libs/code/tests/unit_tests/test_reload.py b/libs/code/tests/unit_tests/test_reload.py index da6ca62382..bd2fc9c12b 100644 --- a/libs/code/tests/unit_tests/test_reload.py +++ b/libs/code/tests/unit_tests/test_reload.py @@ -953,6 +953,70 @@ async def _blocked_reload() -> None: assert typed == "hi" + @pytest.mark.timeout(15) + async def test_queues_prompt_for_entire_reload( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A prompt submitted before restart must wait for reload completion.""" + from deepagents_code.app import DeepAgentsApp + + app = DeepAgentsApp(agent=MagicMock()) + async with app.run_test() as pilot: + await pilot.pause() + started = asyncio.Event() + release = asyncio.Event() + + async def _blocked_reload() -> None: + started.set() + await release.wait() + + monkeypatch.setattr(app, "_run_reload", _blocked_reload) + + task = app._schedule_reload() + await started.wait() + await app._submit_input("do not interrupt", "normal") + + assert app._reloading is True + assert [message.text for message in app._pending_messages] == [ + "do not interrupt" + ] + + release.set() + await task + + @pytest.mark.timeout(15) + async def test_coalesces_overlapping_reloads( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A second `/reload` shares the first task rather than racing it.""" + from deepagents_code.app import DeepAgentsApp + + app = DeepAgentsApp(agent=MagicMock()) + async with app.run_test() as pilot: + await pilot.pause() + started = asyncio.Event() + release = asyncio.Event() + runs = 0 + + async def _blocked_reload() -> None: + nonlocal runs + runs += 1 + started.set() + await release.wait() + + monkeypatch.setattr(app, "_run_reload", _blocked_reload) + + first = app._schedule_reload() + await started.wait() + second = app._schedule_reload() + + assert second is first + assert runs == 1 + + release.set() + await first + assert app._reloading is False + class TestReloadModelProfileHints: """`/reload` should refresh profile-derived command hints.""" From b717311532af0f43cb40e754d2e157ee67fc72da Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Mon, 17 Aug 2026 14:00:39 -0400 Subject: [PATCH 4/6] fix(code): preserve queued prompts during reload --- libs/code/deepagents_code/app.py | 11 ++++--- libs/code/tests/unit_tests/test_reload.py | 39 +++++++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/libs/code/deepagents_code/app.py b/libs/code/deepagents_code/app.py index db77c61337..66fb565448 100644 --- a/libs/code/deepagents_code/app.py +++ b/libs/code/deepagents_code/app.py @@ -15023,20 +15023,23 @@ async def _run_reload(self) -> None: restarted = False if self._server_proc is not None and self._server_kwargs is not None: if self._agent_running and self._agent_worker: + # `/reload` runs detached, so submissions can queue + # while discovery is in progress. `_cancel_worker()` + # clears the queue, but these messages belong to the + # reload rather than the cancelled agent turn. + preserved = list(self._pending_messages) self._cancel_worker(self._agent_worker) # Via `_set_agent_running` so the quiescence event is # released with the flag; a bare assignment leaves # `_agent_quiescent` cleared. self._set_agent_running(False) - preserved: list[QueuedMessage] = [] else: # `/reload` now runs detached, so the user may have # submitted messages that queued while the reload was # busy. The restart's `_discard_queue()` would silently # drop them; snapshot and restore them instead. Only the - # idle path preserves: the running-agent path above - # cancels a turn and intentionally drops its backlog so - # nothing fires against the respawned agent. + # idle path reaches `_discard_queue()` directly; the + # running-agent path snapshots before its cancellation. preserved = list(self._pending_messages) self._discard_queue() restarted = await self._restart_server_manual() diff --git a/libs/code/tests/unit_tests/test_reload.py b/libs/code/tests/unit_tests/test_reload.py index bd2fc9c12b..eb11ad3571 100644 --- a/libs/code/tests/unit_tests/test_reload.py +++ b/libs/code/tests/unit_tests/test_reload.py @@ -2289,3 +2289,42 @@ async def _fake_restart() -> bool: # noqa: RUF029 assert app._session_plugin_ids == expected_ids assert order == ["hooks", "restart"] + + async def test_preserves_messages_queued_before_cancelling_for_restart( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Reload retains prompts queued while its active turn is cancelled.""" + from deepagents_code.app import DeepAgentsApp, QueuedMessage + from deepagents_code.plugins.models import PluginDiscoveryResult + + app = DeepAgentsApp() + async with app.run_test() as pilot: + await pilot.pause() + app._server_proc = MagicMock() + app._server_kwargs = {} + app._agent_worker = MagicMock() + app._set_agent_running(True) + app._pending_messages.append(QueuedMessage("follow up", "normal")) + + async def _fake_discover() -> bool: # noqa: RUF029 + return True + + async def _fake_restart() -> bool: # noqa: RUF029 + return True + + monkeypatch.setattr(app, "_discover_skills", _fake_discover) + monkeypatch.setattr(app, "_reload_hooks", AsyncMock()) + monkeypatch.setattr(app, "_restart_server_manual", _fake_restart) + monkeypatch.setattr(app, "_cancel_worker", app._discard_queue) + monkeypatch.setattr( + "deepagents_code.plugins.discover_plugins", + lambda: PluginDiscoveryResult(plugins=()), + ) + monkeypatch.setattr( + "deepagents_code.plugins.adapters.mcp.plugin_mcp_configs", + lambda _plugins: (), + ) + + await app._run_reload() + + assert [message.text for message in app._pending_messages] == ["follow up"] From d6733ddf0592917cd94b615f37e63314028ddd5c Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Mon, 17 Aug 2026 14:08:01 -0400 Subject: [PATCH 5/6] fix(code): serialize reload teardown --- libs/code/deepagents_code/app.py | 21 ++++++++++++++ libs/code/tests/unit_tests/test_app.py | 34 +++++++++++++++++++++++ libs/code/tests/unit_tests/test_reload.py | 34 +++++++++++++++++++++++ 3 files changed, 89 insertions(+) diff --git a/libs/code/deepagents_code/app.py b/libs/code/deepagents_code/app.py index 66fb565448..771735c4ed 100644 --- a/libs/code/deepagents_code/app.py +++ b/libs/code/deepagents_code/app.py @@ -19019,6 +19019,13 @@ def exit( restart_task = self._restart_respawn_task if restart_task is not None and not restart_task.done(): restart_tasks.add(restart_task) + # `/reload` runs in a detached task and may itself respawn the owned + # server. Treat it like other restart-capable work: cancel and settle + # it before stopping the server so it cannot rebuild the agent after + # teardown begins. + reload_task = self._reload_task + if reload_task is not None and not reload_task.done(): + restart_tasks.add(reload_task) should_wait_for_restart = bool(restart_tasks) # Already cancelled above; awaited in the bounded teardown phase below so # a continuation's cancellation handler (which may be killing a `uv` @@ -24594,6 +24601,20 @@ async def _handle_restart_command(self, command: str) -> None: """ await self._mount_message(UserMessage(command)) + # `/reload` can restart the owned server after refreshing plugins. + # `/restart` normally bypasses every busy state, but starting a second + # respawn here would race that reload and could leave the final agent + # bound to stale plugin state. The active reload already includes the + # restart, so coalesce this request with it. + if self._reloading: + await self._mount_message( + AppMessage( + "Reload already in progress; the agent server will restart " + "when it finishes." + ) + ) + return + # A duplicate `/restart` bypasses the normal input queue while the # first detached respawn is still connecting. Reject it before the # destructive setup below so prompts queued during that respawn are diff --git a/libs/code/tests/unit_tests/test_app.py b/libs/code/tests/unit_tests/test_app.py index c22ecd685d..57457b9539 100644 --- a/libs/code/tests/unit_tests/test_app.py +++ b/libs/code/tests/unit_tests/test_app.py @@ -20381,6 +20381,40 @@ async def respawn( server_proc.stop.assert_called_once_with() super_exit.assert_called_once() + async def test_exit_cancels_reload_before_stopping_server(self) -> None: + """Shutdown settles detached reload work before stopping its server.""" + reload_started = asyncio.Event() + cleanup_started = asyncio.Event() + release_cleanup = asyncio.Event() + + async def blocked_reload() -> None: + reload_started.set() + try: + await asyncio.Event().wait() + finally: + cleanup_started.set() + await release_cleanup.wait() + + server_proc = MagicMock() + app = DeepAgentsApp() + async with app.run_test() as pilot: + await pilot.pause() + app._server_proc = server_proc + app._reload_task = asyncio.create_task(blocked_reload()) + await reload_started.wait() + + with patch.object(App, "exit") as super_exit: + app.exit() + assert app._graceful_exit_task is not None + await asyncio.wait_for(cleanup_started.wait(), timeout=2.0) + + server_proc.stop.assert_not_called() + release_cleanup.set() + await asyncio.wait_for(app._graceful_exit_task, timeout=2.0) + + server_proc.stop.assert_called_once_with() + super_exit.assert_called_once() + async def test_exit_handles_restart_task_in_both_collections(self) -> None: """A `/restart` task tracked in both places is handled once by teardown. diff --git a/libs/code/tests/unit_tests/test_reload.py b/libs/code/tests/unit_tests/test_reload.py index eb11ad3571..77a12ad2a0 100644 --- a/libs/code/tests/unit_tests/test_reload.py +++ b/libs/code/tests/unit_tests/test_reload.py @@ -1017,6 +1017,40 @@ async def _blocked_reload() -> None: await first assert app._reloading is False + @pytest.mark.timeout(15) + async def test_coalesces_restart_with_active_reload( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """`/restart` does not race the restart already planned by `/reload`.""" + from deepagents_code.app import AppMessage, DeepAgentsApp + + app = DeepAgentsApp(agent=MagicMock()) + async with app.run_test() as pilot: + await pilot.pause() + started = asyncio.Event() + release = asyncio.Event() + + async def _blocked_reload() -> None: + started.set() + await release.wait() + + restart = AsyncMock(return_value=True) + monkeypatch.setattr(app, "_run_reload", _blocked_reload) + monkeypatch.setattr(app, "_restart_server_manual", restart) + + task = app._schedule_reload() + await started.wait() + await app._handle_command("/restart") + + restart.assert_not_awaited() + assert any( + "Reload already in progress" in str(message._content) + for message in app.query(AppMessage) + ) + + release.set() + await task + class TestReloadModelProfileHints: """`/reload` should refresh profile-derived command hints.""" From 9bbfe72a8eeb85fb42315844f48e137dade02165 Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Mon, 17 Aug 2026 15:36:34 -0400 Subject: [PATCH 6/6] fix(code): preserve restart requests during reload --- libs/code/deepagents_code/app.py | 98 ++++++++++++++++------- libs/code/tests/unit_tests/test_reload.py | 95 +++++++++++++++++++++- 2 files changed, 161 insertions(+), 32 deletions(-) diff --git a/libs/code/deepagents_code/app.py b/libs/code/deepagents_code/app.py index 771735c4ed..e1989bc69b 100644 --- a/libs/code/deepagents_code/app.py +++ b/libs/code/deepagents_code/app.py @@ -4216,6 +4216,9 @@ def __init__( cancel the prompt's worker. """ + self._restart_requested_during_reload = False + """Whether an explicit `/restart` still needs a reload-owned respawn.""" + self._plugin_fingerprints: dict[str, _PluginFingerprint] | None = None """Rolling plugin-fingerprint baseline keyed by plugin id. @@ -14800,15 +14803,16 @@ def _schedule_reload(self) -> asyncio.Task[None]: Returns: The reload task, so tests can await completion. """ - if self._reloading and self._reload_task is not None: + reload_task = self._reload_task + if self._reloading and reload_task is not None and not reload_task.done(): self.notify("Reload already in progress.", severity="information") - return self._reload_task + return reload_task # Set the guard before scheduling: `create_task` does not run the # coroutine inline, so otherwise a prompt or second reload can enter # in the gap before `_run_reload` starts. self._reloading = True - task = asyncio.create_task(self._run_reload(), name="reload") + task = asyncio.create_task(self._run_reload_sequence(), name="reload") task.add_done_callback(_log_task_exception) task.add_done_callback(self._finish_reload) self._reload_task = task @@ -14823,11 +14827,34 @@ def _finish_reload(self, task: asyncio.Task[None]) -> None: if task is not self._reload_task: return self._reloading = False - if self._pending_messages and not self._agent_running: + if ( + self._pending_messages + and not self._agent_running + and self._agent is not None + ): self.call_after_refresh( lambda: asyncio.create_task(self._process_next_from_queue()), ) + async def _run_reload_sequence(self) -> None: + """Run reload work, then honor restart requests it did not satisfy. + + Raises: + asyncio.CancelledError: If app teardown cancels the active reload. + """ + try: + await self._run_reload() + except asyncio.CancelledError: + self._restart_requested_during_reload = False + raise + + if self._exiting: + self._restart_requested_during_reload = False + return + while self._restart_requested_during_reload: + self._restart_requested_during_reload = False + await self._run_restart_command(preserve_queue=True) + async def _run_reload(self) -> None: """Refresh config, themes, skills, plugins, and hooks, then report. @@ -15042,10 +15069,13 @@ async def _run_reload(self) -> None: # running-agent path snapshots before its cancellation. preserved = list(self._pending_messages) self._discard_queue() - restarted = await self._restart_server_manual() - if preserved: - self._pending_messages.extendleft(reversed(preserved)) - self._sync_status_queued() + try: + restarted = await self._restart_server_manual() + finally: + self._restart_requested_during_reload = False + if preserved: + self._pending_messages.extendleft(reversed(preserved)) + self._sync_status_queued() if restarted: self._session_plugin_ids = discovered_plugin_ids report += "\nAgent server restarted for plugin MCP." @@ -24591,30 +24621,39 @@ async def _handle_restart_command(self, command: str) -> None: server subprocess. Used as a recovery escape hatch when the server wedges. - Cancels any in-flight agent work and drops the queued message - backlog before respawning. The streaming HTTP connection to the - dying subprocess would otherwise raise into the Textual reactor + A direct restart cancels in-flight agent work and drops the queued + message backlog before respawning. The streaming HTTP connection to + the dying subprocess would otherwise raise into the Textual reactor after the new server advertises ready, leaving the UI wedged. Args: command: Raw command string for echoing back to chat. """ - await self._mount_message(UserMessage(command)) + # Snapshot active reload state before mounting the command echo, which + # yields to the reload task. Recording the intent first lets that task + # consume or honor it even if it finishes while the message is mounted. + reload_task = self._reload_task + defer_restart = ( + self._reloading and reload_task is not None and not reload_task.done() + ) + if defer_restart: + self._restart_requested_during_reload = True - # `/reload` can restart the owned server after refreshing plugins. - # `/restart` normally bypasses every busy state, but starting a second - # respawn here would race that reload and could leave the final agent - # bound to stale plugin state. The active reload already includes the - # restart, so coalesce this request with it. - if self._reloading: + await self._mount_message(UserMessage(command)) + if defer_restart: await self._mount_message( - AppMessage( - "Reload already in progress; the agent server will restart " - "when it finishes." - ) + AppMessage("Reload already in progress; server restart requested.") ) return + await self._run_restart_command() + + async def _run_restart_command(self, *, preserve_queue: bool = False) -> None: + """Validate and schedule a restart without echoing another command. + + Args: + preserve_queue: Keep prompts submitted during an active reload. + """ # A duplicate `/restart` bypasses the normal input queue while the # first detached respawn is still connecting. Reject it before the # destructive setup below so prompts queued during that respawn are @@ -24632,15 +24671,18 @@ async def _handle_restart_command(self, command: str) -> None: ) return - # Sever in-flight work bound to the dying subprocess. `_cancel_worker` - # discards the queued backlog too — those messages would otherwise - # fire against the freshly respawned agent silently. This restart *is* - # the reconnect, so suppress the dropped-reconnect warning: the respawn - # below reloads every on-disk MCP token regardless. + # Sever in-flight work bound to the dying subprocess. A direct restart + # drops the queued backlog, but a restart requested during `/reload` + # preserves prompts that the reload guard already accepted for later. + preserved = None if self._agent_running and self._agent_worker: + preserved = list(self._pending_messages) if preserve_queue else None self._cancel_worker(self._agent_worker, abort_pending_reconnect=False) - else: + elif not preserve_queue: self._discard_queue() + if preserved is not None: + self._pending_messages.extendleft(reversed(preserved)) + self._sync_status_queued() if not await self._reload_configuration_for_restart(): return diff --git a/libs/code/tests/unit_tests/test_reload.py b/libs/code/tests/unit_tests/test_reload.py index 77a12ad2a0..22171d0b3d 100644 --- a/libs/code/tests/unit_tests/test_reload.py +++ b/libs/code/tests/unit_tests/test_reload.py @@ -1017,16 +1017,20 @@ async def _blocked_reload() -> None: await first assert app._reloading is False + @pytest.mark.parametrize("restarted", [True, False]) @pytest.mark.timeout(15) - async def test_coalesces_restart_with_active_reload( - self, monkeypatch: pytest.MonkeyPatch + async def test_runs_requested_restart_when_reload_skips_respawn( + self, monkeypatch: pytest.MonkeyPatch, *, restarted: bool ) -> None: - """`/restart` does not race the restart already planned by `/reload`.""" + """A skipped reload respawn preserves `/restart` and queued prompts.""" from deepagents_code.app import AppMessage, DeepAgentsApp + from deepagents_code.config import settings app = DeepAgentsApp(agent=MagicMock()) async with app.run_test() as pilot: await pilot.pause() + app._server_proc = MagicMock() + app._server_kwargs = {} started = asyncio.Event() release = asyncio.Event() @@ -1034,13 +1038,18 @@ async def _blocked_reload() -> None: started.set() await release.wait() - restart = AsyncMock(return_value=True) + restart = AsyncMock(return_value=restarted) monkeypatch.setattr(app, "_run_reload", _blocked_reload) monkeypatch.setattr(app, "_restart_server_manual", restart) + monkeypatch.setattr(settings, "reload_from_environment", list) + monkeypatch.setattr( + "deepagents_code.model_config.clear_caches", lambda: None + ) task = app._schedule_reload() await started.wait() await app._handle_command("/restart") + await app._submit_input("keep this prompt", "normal") restart.assert_not_awaited() assert any( @@ -1050,6 +1059,84 @@ async def _blocked_reload() -> None: release.set() await task + assert app._restart_respawn_task is not None + await app._restart_respawn_task + + restart.assert_awaited_once() + assert [message.text for message in app._pending_messages] == [ + "keep this prompt" + ] + + @pytest.mark.parametrize("restart_raises", [False, True]) + @pytest.mark.timeout(15) + async def test_reload_respawn_consumes_requested_restart( + self, monkeypatch: pytest.MonkeyPatch, *, restart_raises: bool + ) -> None: + """A `/restart` requested during reload's respawn does not run twice.""" + from deepagents_code.app import DeepAgentsApp, UserMessage + from deepagents_code.config import settings + from deepagents_code.plugins.models import PluginDiscoveryResult + + app = DeepAgentsApp(agent=MagicMock()) + async with app.run_test() as pilot: + await pilot.pause() + app._server_proc = MagicMock() + app._server_kwargs = {} + started = asyncio.Event() + release = asyncio.Event() + echo_started = asyncio.Event() + release_echo = asyncio.Event() + + async def _fake_discover() -> bool: # noqa: RUF029 + return True + + async def _blocked_restart() -> bool: + started.set() + await release.wait() + if restart_raises: + msg = "respawn exploded" + raise RuntimeError(msg) + return True + + mount_message = app._mount_message + + async def _blocked_command_echo(widget: UserMessage) -> bool: + if isinstance(widget, UserMessage): + echo_started.set() + await release_echo.wait() + return await mount_message(widget) + + restart = AsyncMock(side_effect=_blocked_restart) + monkeypatch.setattr(app, "_mount_message", _blocked_command_echo) + monkeypatch.setattr(app, "_discover_skills", _fake_discover) + monkeypatch.setattr(app, "_reload_hooks", AsyncMock()) + monkeypatch.setattr(app, "_restart_server_manual", restart) + monkeypatch.setattr(settings, "reload_from_environment", list) + monkeypatch.setattr( + "deepagents_code.model_config.clear_caches", lambda: None + ) + monkeypatch.setattr( + "deepagents_code.plugins.discover_plugins", + lambda: PluginDiscoveryResult(plugins=()), + ) + monkeypatch.setattr( + "deepagents_code.plugins.adapters.mcp.plugin_mcp_configs", + lambda _plugins: (), + ) + + task = app._schedule_reload() + await started.wait() + command_task = asyncio.create_task(app._handle_command("/restart")) + await echo_started.wait() + + assert restart.await_count == 1 + release.set() + await task + release_echo.set() + await command_task + + restart.assert_awaited_once() + assert app._restart_respawn_task is None class TestReloadModelProfileHints: