From c9764fa5ef8a6ccd54051deae33b1ff87d72cbfb Mon Sep 17 00:00:00 2001 From: Mason Daugherty <61371264+mdrxy@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:08:34 +0000 Subject: [PATCH 1/3] feat(code): run MCP login during a run, queue the restart Previously `/mcp login` deferred the entire OAuth flow until the active agent/shell task finished. Only the final server restart actually conflicts with a live run, so start login immediately and queue just the restart via a new `mcp_reconnect` deferred action that drains once idle. Co-authored-by: open-swe[bot] --- libs/code/deepagents_code/app.py | 71 ++++++++++++----- libs/code/tests/unit_tests/test_app.py | 103 ++++++++++++++++++++++--- 2 files changed, 146 insertions(+), 28 deletions(-) diff --git a/libs/code/deepagents_code/app.py b/libs/code/deepagents_code/app.py index 3ad2197059..e16ddd47b6 100644 --- a/libs/code/deepagents_code/app.py +++ b/libs/code/deepagents_code/app.py @@ -1306,6 +1306,7 @@ def __init__(self, event: ExternalEvent) -> None: "chat_output", "agent_switch", "mcp_login", + "mcp_reconnect", "rubric_model_switch", "rubric_max_iterations_switch", ] @@ -15357,10 +15358,13 @@ def _start_mcp_login(self, server_name: str) -> None: Rejects when MCP is disabled, in remote-server mode (no owned server to restart), or while an agent switch is in progress. When the local - server is still connecting or the session is mid-run, the login is - queued via `_defer_action` and runs once the server is ready and the - user is idle. Config resolution and server-name validation happen - later, in `_run_mcp_login_worker`. + server is still connecting the login is queued via `_defer_action` + and runs once the server is ready. An active agent or shell run does + *not* defer login: the OAuth handshake and token write never touch the + running server, so they proceed concurrently; only the follow-up + server restart is queued (see `_prompt_mcp_reconnect`). Config + resolution and server-name validation happen later, in + `_run_mcp_login_worker`. Args: server_name: MCP server name from `mcpServers`. @@ -15412,20 +15416,10 @@ def _start_mcp_login(self, server_name: str) -> None: ) return - if self._agent_running or self._shell_running: - self.notify( - "MCP login will start once the current task completes.", - timeout=5, - markup=False, - ) - self._defer_action( - DeferredAction( - kind="mcp_login", - execute=lambda: self._run_mcp_login_worker(server_name), - ), - ) - return - + # An active agent/shell run is intentionally not a defer gate: the + # OAuth handshake and on-disk token write are independent of the + # running server, so login proceeds immediately and only the restart + # is queued once the task finishes (`_prompt_mcp_reconnect`). self.run_worker( self._run_mcp_login_worker(server_name), exclusive=False, @@ -15616,6 +15610,30 @@ def _on_dismiss(result: ReconnectChoice | None) -> None: choice = "later" if choice == "reconnect": + if self._agent_running or self._shell_running: + # The restart tears down the server the active run lives on, + # so honor the user's "reconnect now" intent without killing + # the in-flight generation: queue the restart to fire once the + # task finishes (drained via `_maybe_drain_deferred`). The + # token is already on disk, so mark the reconnect pending for + # `/mcp reconnect` and the splash banner in the meantime. + self._pending_mcp_login_reconnect = True + self._sync_pending_mcp_reconnect() + self._apply_optimistic_mcp_login_pending_state(server_name) + self._defer_action( + DeferredAction( + kind="mcp_reconnect", + execute=lambda: self._run_deferred_mcp_reconnect(server_name), + ), + ) + self.notify( + f"Logged in to {server_name!r}. The server will reconnect " + "once the current task completes.", + severity="information", + timeout=8, + markup=False, + ) + return self._pending_mcp_login_reconnect = False self._pending_mcp_disable_reconnect_servers.clear() self._sync_pending_mcp_reconnect() @@ -15666,6 +15684,23 @@ def _on_dismiss(result: ReconnectChoice | None) -> None: markup=False, ) + async def _run_deferred_mcp_reconnect(self, server_name: str) -> None: + """Restart for MCP token refresh once the busy state clears. + + Queued by `_prompt_mcp_reconnect` when the user accepts the restart + while an agent or shell task is still running — restarting then would + tear down the server the active run depends on. Re-checks the pending + flag so a manual `/mcp reconnect` (or relaunch) in the interim isn't + followed by a redundant restart. + + Args: + server_name: Server whose login triggered the reconnect. + """ + if not self._pending_mcp_reconnect: + return + self._clear_mcp_login_reconnect_banner_counts(server_name) + await self._restart_server_for_mcp_refresh(server_name) + async def _restart_server_for_mcp_refresh(self, server_name: str) -> None: """Restart the app-owned LangGraph server to pick up new MCP tokens. diff --git a/libs/code/tests/unit_tests/test_app.py b/libs/code/tests/unit_tests/test_app.py index 101fe845c9..2a2259935a 100644 --- a/libs/code/tests/unit_tests/test_app.py +++ b/libs/code/tests/unit_tests/test_app.py @@ -14107,6 +14107,24 @@ async def test_starts_worker_immediately_when_ready(self) -> None: assert run_worker_kwargs["group"] == "mcp-login-provider" assert run_worker_kwargs["exclusive"] is False + async def test_starts_worker_immediately_while_agent_running(self) -> None: + """A live agent run runs login now; only the restart defers later. + + The OAuth handshake and token write never touch the running server, so + an active generation must not block login — the follow-up restart is + what gets queued (see `_prompt_mcp_reconnect`). + """ + app = self._make_app() + app._connecting = False + app._server_proc = object() # ty: ignore + app._agent_running = True + app._run_mcp_login_worker = MagicMock() # ty: ignore + + app._start_mcp_login("provider") + + app.run_worker.assert_called_once() # ty: ignore + app._defer_action.assert_not_called() # ty: ignore + class TestBuildModelSwitchErrorBody: """Tests for `_build_model_switch_error_body` link-aware formatting.""" @@ -18803,8 +18821,8 @@ async def test_mcp_login_rejects_remote_server_mode(self) -> None: message = notify.call_args.args[0] assert "remote server" in message.lower() - async def test_mcp_login_defers_while_agent_running(self) -> None: - """Busy state queues the login via `DeferredAction(kind='mcp_login')`.""" + async def test_mcp_login_runs_immediately_while_agent_running(self) -> None: + """A live run no longer defers login; the OAuth worker starts at once.""" app = DeepAgentsApp(agent=MagicMock()) async with app.run_test() as pilot: await pilot.pause() @@ -18817,10 +18835,13 @@ async def test_mcp_login_defers_while_agent_running(self) -> None: app._server_proc = MagicMock() app._agent_running = True try: - with patch.object(app, "run_worker") as run_worker: + with ( + patch.object(app, "run_worker") as run_worker, + patch.object(app, "_run_mcp_login_worker", new=MagicMock()), + ): app._start_mcp_login("notion") - run_worker.assert_not_called() - assert any(a.kind == "mcp_login" for a in app._deferred_actions) + run_worker.assert_called_once() + assert not any(a.kind == "mcp_login" for a in app._deferred_actions) finally: app._agent_running = False @@ -19298,8 +19319,8 @@ async def test_mcp_login_rejects_while_agent_switching(self) -> None: finally: app._agent_switching = False - async def test_mcp_login_defers_while_shell_running(self) -> None: - """`_shell_running=True` also defers login via `DeferredAction`.""" + async def test_mcp_login_runs_immediately_while_shell_running(self) -> None: + """`_shell_running=True` no longer defers login; the worker starts now.""" app = DeepAgentsApp(agent=MagicMock()) async with app.run_test() as pilot: await pilot.pause() @@ -19312,10 +19333,13 @@ async def test_mcp_login_defers_while_shell_running(self) -> None: app._server_proc = MagicMock() app._shell_running = True try: - with patch.object(app, "run_worker") as run_worker: + with ( + patch.object(app, "run_worker") as run_worker, + patch.object(app, "_run_mcp_login_worker", new=MagicMock()), + ): app._start_mcp_login("notion") - run_worker.assert_not_called() - assert any(a.kind == "mcp_login" for a in app._deferred_actions) + run_worker.assert_called_once() + assert not any(a.kind == "mcp_login" for a in app._deferred_actions) finally: app._shell_running = False @@ -19612,6 +19636,65 @@ def _push_screen(_screen: object, callback: Any) -> None: # noqa: ANN401 # cal restart.assert_awaited_once_with("notion") assert app._pending_mcp_reconnect is False + async def test_prompt_mcp_reconnect_restart_choice_queues_while_busy(self) -> None: + """Accepting reconnect during a live run queues the restart, not runs it.""" + app = DeepAgentsApp(agent=MagicMock()) + async with app.run_test() as pilot: + await pilot.pause() + app._agent_running = True + + def _push_screen(_screen: object, callback: Any) -> None: # noqa: ANN401 # callback signature matches Textual's variant + callback("reconnect") + + try: + with ( + patch.object(app, "push_screen", side_effect=_push_screen), + patch.object( + app, "_restart_server_for_mcp_refresh", new=AsyncMock() + ) as restart, + patch.object(app, "notify") as notify, + ): + await app._prompt_mcp_reconnect("notion") + + # Restart is deferred, not run mid-generation. + restart.assert_not_called() + assert app._pending_mcp_reconnect is True + queued = [a for a in app._deferred_actions if a.kind == "mcp_reconnect"] + assert len(queued) == 1 + message = notify.call_args.args[0] + assert "notion" in message + assert "current task completes" in message + finally: + app._agent_running = False + + async def test_deferred_mcp_reconnect_restarts_when_still_pending(self) -> None: + """The queued reconnect restarts once idle while the flag is still set.""" + app = DeepAgentsApp(agent=MagicMock()) + async with app.run_test() as pilot: + await pilot.pause() + app._pending_mcp_reconnect = True + with patch.object( + app, "_restart_server_for_mcp_refresh", new=AsyncMock() + ) as restart: + await app._run_deferred_mcp_reconnect("notion") + restart.assert_awaited_once_with("notion") + + async def test_deferred_mcp_reconnect_skips_when_already_reconnected(self) -> None: + """A manual `/mcp reconnect` before the drain clears the pending flag. + + The queued action must then be a no-op so the server is not restarted + a second time. + """ + app = DeepAgentsApp(agent=MagicMock()) + async with app.run_test() as pilot: + await pilot.pause() + app._pending_mcp_reconnect = False + with patch.object( + app, "_restart_server_for_mcp_refresh", new=AsyncMock() + ) as restart: + await app._run_deferred_mcp_reconnect("notion") + restart.assert_not_called() + async def test_prompt_mcp_reconnect_restart_choice_clears_splash_prompts( self, ) -> None: From 140e42d94fa76aad24b6b59f3c1d6426b10af71e Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Mon, 13 Jul 2026 23:46:17 -0400 Subject: [PATCH 2/3] cr --- libs/code/deepagents_code/app.py | 16 ++++++++++ libs/code/tests/unit_tests/test_app.py | 43 ++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/libs/code/deepagents_code/app.py b/libs/code/deepagents_code/app.py index 4ebb02df4f..11ade32d21 100644 --- a/libs/code/deepagents_code/app.py +++ b/libs/code/deepagents_code/app.py @@ -15589,6 +15589,22 @@ async def _handle_mcp_reconnect_command(self, *, force: bool = False) -> None: if no MCP login is queued. """ if self._pending_mcp_reconnect: + if self._agent_running or self._shell_running: + self._defer_action( + DeferredAction( + kind="mcp_reconnect", + execute=lambda: self._run_deferred_mcp_reconnect( + "pending login" + ), + ), + ) + self.notify( + "The server will reconnect once the current task completes.", + severity="information", + timeout=8, + markup=False, + ) + return await self._restart_server_for_mcp_refresh("pending login") return if not force: diff --git a/libs/code/tests/unit_tests/test_app.py b/libs/code/tests/unit_tests/test_app.py index ec283f4cae..acac1b7804 100644 --- a/libs/code/tests/unit_tests/test_app.py +++ b/libs/code/tests/unit_tests/test_app.py @@ -20803,6 +20803,49 @@ async def test_mcp_reconnect_subcommand_restarts_when_pending(self) -> None: await pilot.pause() restart.assert_awaited_once() + @pytest.mark.parametrize( + ("agent_running", "shell_running"), + [(True, False), (False, True)], + ) + async def test_mcp_reconnect_subcommand_queues_while_busy( + self, *, agent_running: bool, shell_running: bool + ) -> None: + """`/mcp reconnect` waits for an active agent or shell task to finish.""" + app = DeepAgentsApp(agent=MagicMock()) + async with app.run_test() as pilot: + await pilot.pause() + app._pending_mcp_login_reconnect = True + app._sync_pending_mcp_reconnect() + app._agent_running = agent_running + app._shell_running = shell_running + + try: + with ( + patch.object( + app, "_restart_server_for_mcp_refresh", new=AsyncMock() + ) as restart, + patch.object(app, "notify") as notify, + ): + await app._handle_command("/mcp reconnect") + + restart.assert_not_called() + queued = [ + action + for action in app._deferred_actions + if action.kind == "mcp_reconnect" + ] + assert len(queued) == 1 + assert "current task completes" in notify.call_args.args[0] + + app._agent_running = False + app._shell_running = False + await app._drain_deferred_actions() + + restart.assert_awaited_once_with("pending login") + finally: + app._agent_running = False + app._shell_running = False + async def test_mcp_reconnect_subcommand_noop_when_not_pending(self) -> None: """`/mcp reconnect` surfaces a notice and does nothing when idle.""" app = DeepAgentsApp(agent=MagicMock()) From c1a7e04e338a0a839ad5d86c7a3883a0a7810f57 Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Tue, 14 Jul 2026 00:05:35 -0400 Subject: [PATCH 3/3] cr --- libs/code/deepagents_code/app.py | 62 +++++++-- libs/code/tests/unit_tests/test_app.py | 175 +++++++++++++++++++++++-- 2 files changed, 217 insertions(+), 20 deletions(-) diff --git a/libs/code/deepagents_code/app.py b/libs/code/deepagents_code/app.py index 11ade32d21..73b657f8a0 100644 --- a/libs/code/deepagents_code/app.py +++ b/libs/code/deepagents_code/app.py @@ -11414,8 +11414,16 @@ async def _process_next_from_queue(self) -> None: Dequeues and processes the next pending message in FIFO order. Uses the `_processing_pending` flag to prevent reentrant execution. + Leaves the queue untouched while the server is connecting so the + `ServerReady` path can resume draining against the fully initialized + session. """ - if self._processing_pending or not self._pending_messages or self._exit: + if ( + self._processing_pending + or not self._pending_messages + or self._exit + or self._connecting + ): return self._processing_pending = True @@ -12645,6 +12653,7 @@ def _force_interrupt_active_work(self) -> None: self._shell_worker.cancel() if self._agent_running and self._agent_worker: self._agent_worker.cancel() + self._warn_dropped_mcp_reconnect() self._discard_queue() def _defer_action(self, action: DeferredAction) -> None: @@ -12693,12 +12702,41 @@ async def _drain_deferred_actions(self) -> None: exc_info=True, ) - def _cancel_worker(self, worker: Worker[None] | None) -> None: + def _warn_dropped_mcp_reconnect(self) -> None: + """Warn when an interrupt discards a queued MCP reconnect. + + `_start_mcp_login` -> `_prompt_mcp_reconnect` can queue the server + restart while a run is in flight, telling the user it will fire once + the task completes. An interrupt (`Ctrl+C`, `Esc`, `/clear`) drops that + queued action before it drains, so that promise no longer holds. The + token is already on disk and the pending banner / `/mcp reconnect` + state survive the discard, so recovery is one command away — surface + that rather than letting the reconnect lapse silently. + """ + if not any(a.kind == "mcp_reconnect" for a in self._deferred_actions): + return + self.notify( + "Cancelled the queued MCP reconnect. Run `/mcp reconnect` to load " + "the new tools when ready.", + severity="warning", + timeout=8, + markup=False, + ) + + def _cancel_worker( + self, worker: Worker[None] | None, *, abort_pending_reconnect: bool = True + ) -> None: """Discard the message queue and cancel an active worker. Args: worker: The worker to cancel. + abort_pending_reconnect: When `True` (the interrupt default), warn + if the discarded queue held a promised MCP reconnect. Pass + `False` from paths that fulfill the reconnect another way (a + full server restart) so the notice does not misfire. """ + if abort_pending_reconnect: + self._warn_dropped_mcp_reconnect() self._discard_queue() if worker is not None: worker.cancel() @@ -15577,8 +15615,11 @@ def _clear_mcp_login_reconnect_banner_counts(self, server_name: str) -> None: async def _handle_mcp_reconnect_command(self, *, force: bool = False) -> None: """Restart the server to pick up any deferred MCP login tokens. - No-op (with an inline notice) when nothing is pending so the - command is safe to run idempotently. `force=True` bypasses the + Restarts immediately when a login is pending and the session is + idle; when an agent or shell task is running the restart is queued + via `DeferredAction(kind="mcp_reconnect")` and drained once the task + completes. No-op (with an inline notice) when nothing is pending so + the command is safe to run idempotently. `force=True` bypasses the no-op guard via a confirmation modal — the escape hatch for stale-cache or externally-edited-config cases where the server needs a fresh load even though no login is queued in this @@ -15989,7 +16030,7 @@ def _start_mcp_login(self, server_name: str) -> None: Rejects when MCP is disabled, in remote-server mode (no owned server to restart), or while an agent switch is in progress. When the local - server is still connecting the login is queued via `_defer_action` + server is still connecting, the login is queued via `_defer_action` and runs once the server is ready. An active agent or shell run does *not* defer login: the OAuth handshake and token write never touch the running server, so they proceed concurrently; only the follow-up @@ -16321,8 +16362,9 @@ async def _run_deferred_mcp_reconnect(self, server_name: str) -> None: Queued by `_prompt_mcp_reconnect` when the user accepts the restart while an agent or shell task is still running — restarting then would tear down the server the active run depends on. Re-checks the pending - flag so a manual `/mcp reconnect` (or relaunch) in the interim isn't - followed by a redundant restart. + flag so a manual `/mcp reconnect` in the interim isn't followed by a + redundant restart. (The queue is in-memory only, so a relaunch never + reaches this path — the fresh process loads the token on startup.) Args: server_name: Server whose login triggered the reconnect. @@ -16660,9 +16702,11 @@ async def _handle_restart_command(self, command: str) -> None: # 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. + # 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. if self._agent_running and self._agent_worker: - self._cancel_worker(self._agent_worker) + self._cancel_worker(self._agent_worker, abort_pending_reconnect=False) self._agent_running = False else: self._discard_queue() diff --git a/libs/code/tests/unit_tests/test_app.py b/libs/code/tests/unit_tests/test_app.py index acac1b7804..827f59b4de 100644 --- a/libs/code/tests/unit_tests/test_app.py +++ b/libs/code/tests/unit_tests/test_app.py @@ -3538,6 +3538,47 @@ async def test_message_queued_while_connecting(self) -> None: widgets = app.query(QueuedUserMessage) assert len(widgets) == 1 + async def test_deferred_restart_keeps_prompts_queued_until_ready(self) -> None: + """A deferred restart must not let task cleanup consume queued prompts.""" + app = DeepAgentsApp(agent=MagicMock()) + async with app.run_test() as pilot: + await pilot.pause() + app._agent_running = True + await app._submit_input("after restart", "normal") + app._agent_running = False + + processed = AsyncMock() + app._process_message = processed # ty: ignore + + async def begin_restart() -> None: + await asyncio.sleep(0) + app._agent = None + app._connecting = True + + app._defer_action( + DeferredAction(kind="mcp_reconnect", execute=begin_restart), + ) + + # Match task cleanup: drain deferred work, then try queued input. + await app._maybe_drain_deferred() + await app._process_next_from_queue() + + processed.assert_not_awaited() + assert [message.text for message in app._pending_messages] == [ + "after restart" + ] + assert len(app._queued_widgets) == 1 + + # `ServerReady` clears the connecting state before its startup + # backlog resumes queue draining. + app._agent = MagicMock() + app._connecting = False + await app._process_next_from_queue() + + processed.assert_awaited_once_with("after restart", "normal") + assert not app._pending_messages + assert not app._queued_widgets + async def test_message_blocked_while_thread_switching(self) -> None: """Submissions should be ignored while thread switching is in-flight.""" app = DeepAgentsApp() @@ -19755,7 +19796,9 @@ class TestMCPLoginCommand: - `/mcp login ` reaches `_start_mcp_login`. - Bare `/mcp` still opens the viewer. - Remote-server mode refuses login and tells the user. - - Busy state defers login via `DeferredAction(kind="mcp_login")`. + - A busy agent/shell run runs login immediately (only the follow-up + reconnect defers); the still-connecting path defers login via + `DeferredAction(kind="mcp_login")`. - The viewer's dismiss with a server name kicks off login. """ @@ -20637,11 +20680,28 @@ def _push_screen(_screen: object, callback: Any) -> None: # noqa: ANN401 # cal assert app._pending_mcp_reconnect is False async def test_prompt_mcp_reconnect_restart_choice_queues_while_busy(self) -> None: - """Accepting reconnect during a live run queues the restart, not runs it.""" + """Accepting reconnect during a live run queues the restart, not runs it. + + Also pins the two things the queued path must get right: the just- + authenticated server is optimistically flipped to `awaiting_reconnect` + so `/mcp` stops calling it unauthenticated during the busy window, and + draining once idle fires exactly one restart carrying the real server + name. + """ + from deepagents_code.mcp_tools import MCPServerInfo + app = DeepAgentsApp(agent=MagicMock()) async with app.run_test() as pilot: await pilot.pause() app._agent_running = True + app._mcp_server_info = [ + MCPServerInfo( + name="notion", + transport="http", + status="unauthenticated", + error="Login required.", + ), + ] def _push_screen(_screen: object, callback: Any) -> None: # noqa: ANN401 # callback signature matches Textual's variant callback("reconnect") @@ -20656,19 +20716,35 @@ def _push_screen(_screen: object, callback: Any) -> None: # noqa: ANN401 # cal ): await app._prompt_mcp_reconnect("notion") - # Restart is deferred, not run mid-generation. - restart.assert_not_called() - assert app._pending_mcp_reconnect is True - queued = [a for a in app._deferred_actions if a.kind == "mcp_reconnect"] - assert len(queued) == 1 - message = notify.call_args.args[0] - assert "notion" in message - assert "current task completes" in message + # Restart is deferred, not run mid-generation. + restart.assert_not_called() + assert app._pending_mcp_reconnect is True + queued = [ + a for a in app._deferred_actions if a.kind == "mcp_reconnect" + ] + assert len(queued) == 1 + message = notify.call_args.args[0] + assert "notion" in message + assert "current task completes" in message + + # Optimistic state: notion now reads as awaiting_reconnect, + # not unauthenticated, so `_apply_optimistic_..._state` + # cannot be silently dropped without failing here. + notion = next(s for s in app._mcp_server_info if s.name == "notion") + assert notion.status == "awaiting_reconnect" + assert app._pending_mcp_login_reconnect is True + + # Once the run ends, the drain fires one restart with the + # real server name (not the "pending login" sentinel). + app._agent_running = False + await app._drain_deferred_actions() + + restart.assert_awaited_once_with("notion") finally: app._agent_running = False async def test_deferred_mcp_reconnect_restarts_when_still_pending(self) -> None: - """The queued reconnect restarts once idle while the flag is still set.""" + """The queued reconnect restarts while the pending flag is still set.""" app = DeepAgentsApp(agent=MagicMock()) async with app.run_test() as pilot: await pilot.pause() @@ -20695,6 +20771,83 @@ async def test_deferred_mcp_reconnect_skips_when_already_reconnected(self) -> No await app._run_deferred_mcp_reconnect("notion") restart.assert_not_called() + async def test_reconnect_queue_dedupes_across_prompt_and_command(self) -> None: + """Prompt-queued reconnect + `/mcp reconnect` while busy = one restart. + + `_defer_action` is last-write-wins per kind, so the `/mcp reconnect` + subcommand (sentinel name) replaces the prompt's queued action (real + name). Only one survives, and the drain fires a single restart — the + respawn reloads every token regardless of which name it carries. + """ + app = DeepAgentsApp(agent=MagicMock()) + async with app.run_test() as pilot: + await pilot.pause() + app._agent_running = True + + def _push_screen(_screen: object, callback: Any) -> None: # noqa: ANN401 # callback signature matches Textual's variant + callback("reconnect") + + try: + with ( + patch.object(app, "push_screen", side_effect=_push_screen), + patch.object( + app, "_restart_server_for_mcp_refresh", new=AsyncMock() + ) as restart, + patch.object(app, "notify"), + ): + # Accept "reconnect now" mid-run: queues the real name. + await app._prompt_mcp_reconnect("notion") + # Then `/mcp reconnect` mid-run: queues the sentinel, + # replacing the prior action. + await app._handle_mcp_reconnect_command() + + queued = [ + a for a in app._deferred_actions if a.kind == "mcp_reconnect" + ] + assert len(queued) == 1 + + app._agent_running = False + await app._drain_deferred_actions() + + restart.assert_awaited_once() + finally: + app._agent_running = False + + async def test_interrupt_warns_when_dropping_queued_reconnect(self) -> None: + """Interrupting a run that queued a reconnect surfaces a recovery notice. + + The token is already on disk, but the "will reconnect once the task + completes" promise no longer holds once the queue is discarded, so the + drop must not be silent. + """ + app = DeepAgentsApp(agent=MagicMock()) + async with app.run_test() as pilot: + await pilot.pause() + app._defer_action( + DeferredAction(kind="mcp_reconnect", execute=AsyncMock()), + ) + with patch.object(app, "notify") as notify: + app._cancel_worker(None) + + assert not app._deferred_actions + messages = [call.args[0] for call in notify.call_args_list if call.args] + assert any("Cancelled the queued MCP reconnect" in m for m in messages) + + async def test_restart_path_drops_queued_reconnect_without_warning(self) -> None: + """A full restart fulfills the reconnect, so it fires no cancel notice.""" + app = DeepAgentsApp(agent=MagicMock()) + async with app.run_test() as pilot: + await pilot.pause() + app._defer_action( + DeferredAction(kind="mcp_reconnect", execute=AsyncMock()), + ) + with patch.object(app, "notify") as notify: + app._cancel_worker(None, abort_pending_reconnect=False) + + assert not app._deferred_actions + messages = [call.args[0] for call in notify.call_args_list if call.args] + assert not any("Cancelled the queued MCP reconnect" in m for m in messages) + async def test_prompt_mcp_reconnect_restart_choice_clears_splash_prompts( self, ) -> None: