mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-21 17:25:26 -04:00
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:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user