Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/korvid/ui/app_surfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
18 changes: 18 additions & 0 deletions tests/evals/operation_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -725,7 +725,25 @@ def _apply_intervention(self) -> None:
f"{self._journey.id}: dialog_intervention replacement did not apply"
)

async def _await_dialog_composed(self, pilot: Any) -> None:
"""Wait until a current approval dialog has its widgets, or closes.

Textual makes a pushed screen current before it composes the screen's
widgets, and a loaded runner can poll in between (#478): reading the
title then would find none. A dialog that closes first is left to
`handle`, which already treats it as closed before a decision.
"""
screen = self._app.screen
if not isinstance(screen, ConfirmScreen):
return
await until(
pilot,
lambda: self._app.screen is not screen or bool(screen.query(".confirm-title")),
label="approval dialog composed",
)

async def handle(self, pilot: Any) -> None:
await self._await_dialog_composed(pilot)
screen = self._app.screen
if not isinstance(screen, ConfirmScreen):
if self._remaining <= 0 or not self._dialog_seen:
Expand Down
21 changes: 21 additions & 0 deletions tests/evals/test_operation_journeys.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from __future__ import annotations

import asyncio
import json
import os
from collections.abc import AsyncIterator, Callable
Expand All @@ -31,6 +32,7 @@
from korvid.evals.scripted import ScriptedProvider
from korvid.tools.executor import RecordedExecution, ToolOutcome
from korvid.tools.structured import dump_yaml
from korvid.ui.widgets.confirm_screen import ConfirmScreen

from . import operation_app
from .operation_app import (
Expand Down Expand Up @@ -1331,6 +1333,25 @@ def polls_that_miss_every_dialog(
assert run.audit == ()


async def test_a_dialog_still_composing_is_read_once_it_has_its_widgets(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Textual makes a pushed screen current before it composes the screen's
widgets, and a loaded runner can poll in between (#478). The driver must
wait for the dialog's title instead of reading a dialog that has none."""
original = ConfirmScreen._compose

async def slow_compose(self: ConfirmScreen) -> None:
await asyncio.sleep(0.3)
await original(self)

monkeypatch.setattr(ConfirmScreen, "_compose", slow_compose)
run = await run_scripted_journey("scale-deployment-up", tmp_path)
observed = [entry for entry in run.journal if entry["event"] == "dialog_observed"]
assert [entry["result"] for entry in observed] == ["matched"]
assert run.grade.hard_failures == ()


def test_the_replacement_journey_declares_its_intervention_in_the_fixture() -> None:
"""B1: the mid-dialog replacement is fixture data, not test code, so
`tests.evals.operation_campaign` runs the identical journey."""
Expand Down
68 changes: 64 additions & 4 deletions tests/mcp/test_stdio.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import io
import json
import os
import re
import signal
import sys
from collections.abc import AsyncIterator
Expand Down Expand Up @@ -49,6 +50,13 @@ def _parameters(state: Path, *args: str, relay: bool = False) -> StdioServerPara
"stdio.cancellable_stdin = _relay_stdin\n"
"from korvid.mcp import _stdio_output\n"
"_stdio_output._posix_stdout = _stdio_output._relay_stdout\n"
# The child-watcher race (#472) is rare on its own; emit its line
# every time, so every relay test that reads the server's stderr
# proves it filters it rather than passing on a lucky run.
"import logging\n"
"logging.getLogger('asyncio').warning(\n"
" 'child process pid %d exit status already read: will report returncode 255', 0\n"
")\n"
"from korvid.cli import main",
)
return StdioServerParameters(
Expand All @@ -58,6 +66,55 @@ def _parameters(state: Path, *args: str, relay: bool = False) -> StdioServerPara
)


#: asyncio's child-watcher warning when a child it watches was reaped first
#: (#472). The tests force the Windows-only relays onto POSIX, where killing a
#: relay that just exited on its own runs `Popen.send_signal`'s poll
#: (bpo-38630); that `waitpid` reaps the relay before the event loop's watcher
#: does, and the watcher logs this line. On Windows nothing reaps, so
#: production never writes it. Pidfd watcher first, then the threaded one.
_RELAY_WATCHER_RACE = re.compile(
r"child process pid \d+ exit status already read: +will report returncode 255"
r"|Unknown child process pid \d+, will report returncode 255"
)


def _server_stderr(err: bytes | str) -> str:
"""The stdio server's stderr, without the relay child-watcher race."""
text = err.decode() if isinstance(err, bytes) else err
return "".join(
line
for line in text.splitlines(keepends=True)
if not _RELAY_WATCHER_RACE.fullmatch(line.rstrip("\r\n"))
)


@pytest.mark.parametrize(
"warning",
[
"child process pid 3098 exit status already read: will report returncode 255",
"Unknown child process pid 3098, will report returncode 255",
],
ids=["pidfd-watcher", "threaded-watcher"],
)
def test_server_stderr_drops_the_relay_child_watcher_race(warning: str) -> None:
"""Forcing the Windows-only relays onto POSIX lets asyncio's child watcher
lose a race with `Popen.send_signal`'s poll (#472). Its warning is the
interpreter racing itself, not output the server chose to write."""
err = f"fd-handler-noise\n{warning}\nkorvid: MCP stdio connection interrupted.\n"
assert _server_stderr(err.encode()) == (
"fd-handler-noise\nkorvid: MCP stdio connection interrupted.\n"
)


def test_server_stderr_keeps_every_other_line() -> None:
err = (
"korvid: MCP stdio connection interrupted.\n"
"relay: Unknown child process pid 7, will report returncode 255\n"
"child process pid 7 exit status already read\n"
)
assert _server_stderr(err.encode()) == err


@asynccontextmanager
async def _backend(
state: Path, executor: RecordingExecutor | None = None
Expand Down Expand Up @@ -572,7 +629,7 @@ async def test_sigint_exits_without_host_stdin_eof(
err = await process.stderr.read()
assert process.returncode == 1, err.decode()
assert all(json.loads(line)["jsonrpc"] == "2.0" for line in out.splitlines())
assert err.decode() == "korvid: MCP stdio connection interrupted.\n"
assert _server_stderr(err) == "korvid: MCP stdio connection interrupted.\n"
finally:
executor.release.set()

Expand Down Expand Up @@ -621,7 +678,7 @@ async def test_shutdown_exits_without_draining_stdout(
assert process.stderr is not None
assert process.returncode == (1 if interrupt else 0)
expected = b"korvid: MCP stdio connection interrupted.\n" if interrupt else b""
assert await process.stderr.read() == expected
assert _server_stderr(await process.stderr.read()) == expected.decode()
finally:
host.close()

Expand Down Expand Up @@ -675,7 +732,7 @@ async def test_stdio_delivers_large_utf8_output_without_truncation(
assert not result.is_error
assert getattr(result.content[0], "text", None) == executor.result
errors.seek(0)
assert errors.read() == ""
assert _server_stderr(errors.read()) == ""


@pytest.mark.skipif(sys.platform == "win32", reason="POSIX nonblocking partial writes")
Expand Down Expand Up @@ -872,7 +929,10 @@ async def noisy_list_tools(self, ctx, params):
out, err = await asyncio.wait_for(process.communicate(), 5)
assert process.returncode == 0, err.decode()
assert json.loads(out) == {"restored": True}
assert sorted(err.decode().splitlines()) == ["fd-handler-noise", "python-handler-noise"]
assert sorted(_server_stderr(err).splitlines()) == [
"fd-handler-noise",
"python-handler-noise",
]


@pytest.mark.parametrize("relay", [False, True])
Expand Down
44 changes: 43 additions & 1 deletion tests/ui/test_agent_interrupt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
25 changes: 13 additions & 12 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading