fix(code): unblock MCP force reconnect modal (#4396)

Fixed `/mcp reconnect force` so its confirmation modal no longer traps
the TUI; Enter confirms, Esc cancels, and the app remains responsive.

---

The MCP force-reconnect confirmation now returns control to Textual's
event loop instead of awaiting the modal from the chat-input submit
handler. That lets Enter, Esc, and quit shortcuts work while the modal
is open, and still runs the reconnect only after explicit confirmation.

## Changes
- Replace the blocking `_push_screen_wait` call in
`_handle_mcp_reconnect_command(force=True)` with callback-style
`push_screen`, so the force-confirm modal can receive follow-up key
events.
- Schedule `_restart_server_for_mcp_refresh("forced reconnect")` from
the confirmation callback and attach `_log_task_exception` so detached
restart failures are still surfaced.
- Treat cancel and programmatic dismiss as safe no-ops that return focus
to the chat input, and notify if the confirmation modal cannot be
mounted.
- Cover the real modal path with pilot-driven tests for Enter, Esc/focus
restoration, mount failure, and the pending-reconnect bypass.
This commit is contained in:
Mason Daugherty
2026-06-30 17:51:46 -04:00
committed by GitHub
parent 3239bf4edf
commit 8b7eab023d
2 changed files with 76 additions and 12 deletions
+32 -3
View File
@@ -13211,9 +13211,38 @@ class DeepAgentsApp(App):
MCPReconnectForceConfirmScreen,
)
confirmed = await self._push_screen_wait(MCPReconnectForceConfirmScreen())
if confirmed:
await self._restart_server_for_mcp_refresh("forced reconnect")
def handle_confirmation(confirmed: bool | None) -> None:
# False (explicit cancel/Esc) and None (programmatic dismiss) are
# intentionally collapsed: in both cases the safe default is to
# leave the server running and return focus to the chat input.
if not confirmed:
if self._chat_input:
self._chat_input.focus_input()
return
# `push_screen` callbacks are synchronous and cannot await, so the
# async restart is scheduled as a detached task. `_log_task_exception`
# surfaces any unhandled failure (the restart also reports expected
# errors via `notify`/`ServerStartFailed` independently of this task).
task = asyncio.create_task(
self._restart_server_for_mcp_refresh("forced reconnect")
)
task.add_done_callback(_log_task_exception)
try:
self.push_screen(MCPReconnectForceConfirmScreen(), handle_confirmation)
except Exception:
# Modal could not be mounted (e.g. another modal hijacked the
# stack). Surface it rather than silently dropping the command,
# mirroring `_prompt_mcp_reconnect`.
logger.exception("Failed to mount MCP reconnect force-confirm modal")
self.notify(
"Couldn't open the reconnect confirmation. Try again, or "
"relaunch dcode to pick up the new MCP token.",
severity="warning",
markup=False,
)
if self._chat_input:
self._chat_input.focus_input()
async def _show_mcp_viewer(self) -> None:
"""Show the MCP server/tool viewer as a modal screen.
+44 -9
View File
@@ -17299,24 +17299,55 @@ class TestMCPLoginCommand:
async def test_mcp_reconnect_force_confirm_restarts(self) -> None:
"""`/mcp reconnect force` restarts after the confirm modal accepts."""
from deepagents_code.widgets.mcp_reconnect import MCPReconnectForceConfirmScreen
app = DeepAgentsApp(agent=MagicMock())
async with app.run_test() as pilot:
await pilot.pause()
assert app._pending_mcp_reconnect is False
with (
patch.object(
app, "_restart_server_for_mcp_refresh", new=AsyncMock()
) as restart,
patch.object(
app, "_push_screen_wait", new=AsyncMock(return_value=True)
),
):
with patch.object(
app, "_restart_server_for_mcp_refresh", new=AsyncMock()
) as restart:
await app._handle_command("/mcp reconnect force")
await pilot.pause()
assert isinstance(app.screen, MCPReconnectForceConfirmScreen)
await pilot.press("enter")
# Two pauses: the first dismisses the modal and runs the
# callback (which schedules the restart task); the second
# lets that detached task reach its awaited body so the
# AsyncMock is recorded as awaited before the assertion.
await pilot.pause()
await pilot.pause()
restart.assert_awaited_once_with("forced reconnect")
async def test_mcp_reconnect_force_cancel_skips_restart(self) -> None:
"""`/mcp reconnect force` does nothing when the confirm modal is cancelled."""
from deepagents_code.widgets.mcp_reconnect import MCPReconnectForceConfirmScreen
app = DeepAgentsApp(agent=MagicMock())
async with app.run_test() as pilot:
await pilot.pause()
with patch.object(
app, "_restart_server_for_mcp_refresh", new=AsyncMock()
) as restart:
await app._handle_command("/mcp reconnect force")
await pilot.pause()
assert isinstance(app.screen, MCPReconnectForceConfirmScreen)
assert app._chat_input is not None
with patch.object(app._chat_input, "focus_input") as focus_input:
await pilot.press("escape")
await pilot.pause()
# Cancelling must return focus to the chat input.
focus_input.assert_called_once_with()
restart.assert_not_called()
async def test_mcp_reconnect_force_surfaces_modal_mount_failure(self) -> None:
"""A failed confirm-modal mount notifies instead of silently dropping."""
app = DeepAgentsApp(agent=MagicMock())
async with app.run_test() as pilot:
await pilot.pause()
@@ -17325,11 +17356,15 @@ class TestMCPLoginCommand:
app, "_restart_server_for_mcp_refresh", new=AsyncMock()
) as restart,
patch.object(
app, "_push_screen_wait", new=AsyncMock(return_value=False)
app, "push_screen", side_effect=RuntimeError("stack hijacked")
),
patch.object(app, "notify") as notify,
):
await app._handle_command("/mcp reconnect force")
await pilot.pause()
notify.assert_called_once()
assert notify.call_args.kwargs["severity"] == "warning"
restart.assert_not_called()
async def test_mcp_reconnect_force_skips_confirm_when_pending(self) -> None: