From 2fd49d728bc5e03b714420a4a7f4296edec7a054 Mon Sep 17 00:00:00 2001 From: hellices Date: Sat, 24 Oct 2026 02:04:10 +0900 Subject: [PATCH] fix: drop agent events that arrive while the app shuts down Textual's App._shutdown clears _running, removes every screen and widget, and only then sends the App its Unmount, which is where KorvidApp marks the agent session down. A turn the user interrupted just before quitting can settle in between. _finish_interrupted_turn then still sees _shutting_down as False and hands its TurnInterrupted to the panel port, which looks the panel up on a pruned screen. The resulting NoMatches fails the turn task and escapes app shutdown through wait_for_turn (#476). On #471's CI it surfaced as "No nodes match '#agent-status' on AgentPanel()". AppAgentPanel.apply_event, the one place agent events reach the panel, now drops an event once the app is no longer running. The new test reproduces the ordering deterministically: the interrupted turn's cleanup waits for the panel's Unmount, so it settles after Textual pruned the panel and before the App's Unmount. Without the guard it fails with NoMatches every run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/korvid/ui/app_surfaces.py | 5 ++++ tests/ui/test_agent_interrupt.py | 44 +++++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/korvid/ui/app_surfaces.py b/src/korvid/ui/app_surfaces.py index 36b1325a9..1dc2b838d 100644 --- a/src/korvid/ui/app_surfaces.py +++ b/src/korvid/ui/app_surfaces.py @@ -184,6 +184,11 @@ def echo_user(self, text: str) -> None: self._app._agent_panel.echo_user(text) def apply_event(self, event: AgentEvent) -> None: + if not self._app.is_running: + # Textual prunes every widget before it sends the App its Unmount, + # which is where the agent session is marked down. A turn that + # settles in between has no panel left to update (#476). + return self._app._agent_panel.apply_event(event) diff --git a/tests/ui/test_agent_interrupt.py b/tests/ui/test_agent_interrupt.py index ba47c68e9..779ecaeda 100644 --- a/tests/ui/test_agent_interrupt.py +++ b/tests/ui/test_agent_interrupt.py @@ -21,7 +21,7 @@ from .agent_write_support import Recorder, _expand_panel from .agent_write_support import make_app as make_write_app -from .waits import until +from .waits import HANG_GUARD_SECONDS, until def test_fake_session_interrupt_usage_matches_live_cumulative_totals() -> None: @@ -335,6 +335,48 @@ async def _slow(self) -> AsyncIterator[AgentEvent]: assert session.cleanup_done +async def test_a_turn_that_settles_after_the_panel_is_pruned_does_not_raise( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Textual removes every widget before it sends the App its Unmount, which + is where the agent session is marked down. A turn interrupted just before + quitting can settle in between, after the panel's children are gone, and + must not raise from shutdown (#476).""" + pruned = asyncio.Event() + + def note_unmount(self: AgentPanel) -> None: + pruned.set() + + monkeypatch.setattr(AgentPanel, "on_unmount", note_unmount, raising=False) + + class PrunedPanelSession(BlockingSession): + def __init__(self) -> None: + super().__init__() + self.settled_after_prune = False + + def run_turn(self, user_text: str) -> AsyncIterator[AgentEvent]: + self.prompts.append(user_text) + return self._until_pruned() + + async def _until_pruned(self) -> AsyncIterator[AgentEvent]: + try: + yield TextDelta(text="let me check") + await asyncio.Event().wait() + finally: + # Settle the interrupted turn as soon as Textual has pruned the + # panel: after `_close_all` removed it, before the App's Unmount. + await asyncio.wait_for(pruned.wait(), timeout=HANG_GUARD_SECONDS) + self.settled_after_prune = True + + session = PrunedPanelSession() + app = make_app(session) + async with app.run_test() as pilot: + await _start_turn(app, pilot, "check") + await until(pilot, lambda: bool(session.prompts), label="turn running") + app.action_interrupt_agent() + assert session.settled_after_prune + + async def test_unmount_marks_the_agent_down_before_its_first_await( monkeypatch: pytest.MonkeyPatch, ) -> None: