Files
jfilipiuk b5b01d50c2 feat: agent-teams part A - TUI panel visibility + DELEGATION_STRATEGY routing (#369)
* feat: document sync / QuickJS panel / async dispatch modes in DELEGATION_STRATEGY

* feat: surface QuickJS panel dispatches in TUI

* fix: finalize running panel dispatches on turn cancellation

* fix: replace panel widget cancel path with public finalize API

* fix: drop dead isinstance guard on panel dispatch duration_ms

* fix: re-arm panel widget when a new dispatch arrives after finalize

* fix: stop panel timer when finalize_running finds zero running rows

* fix: register panel widget in cleanup dict before awaiting mount
2026-08-07 17:07:01 +01:00

195 lines
7.2 KiB
Python

"""Unit tests for PanelWidget (TUI in-eval fan-out live view).
Widget-mount / compose paths need a Textual App context and are exercised
through the interactive TUI end-to-end. Compositor-free tests cover
construction and state-transition helpers; ``TestPanelWidgetReentry`` runs
against a real Textual harness to probe the ``_is_active`` latch behaviour
after finalize (the ``Promise.allSettled`` → retry-failed-subset scenario).
"""
from __future__ import annotations
import unittest
import pytest
pytest.importorskip("textual")
class TestPanelWidgetState:
"""PanelWidget state transitions independent of the compositor."""
def test_construction(self):
from EvoScientist.cli.widgets.panel_widget import PanelWidget
w = PanelWidget("ci_eval_1")
assert w.eval_id == "ci_eval_1"
assert w.dispatch_count == 0
assert w._is_active is True
def test_summary_counts_all_running(self):
from EvoScientist.cli.widgets.panel_widget import PanelWidget, _DispatchRow
w = PanelWidget("e1")
w._rows["d1"] = _DispatchRow("innovator", "a")
w._rows["d2"] = _DispatchRow("pragmatist", "b")
running, ok, err = w._summary_counts()
assert (running, ok, err) == (2, 0, 0)
def test_summary_counts_mixed(self):
from EvoScientist.cli.widgets.panel_widget import PanelWidget, _DispatchRow
w = PanelWidget("e1")
r1 = _DispatchRow("innovator", "a")
r2 = _DispatchRow("pragmatist", "b")
r3 = _DispatchRow("critic", "c")
r1._status = "ok"
r2._status = "err"
w._rows["d1"] = r1
w._rows["d2"] = r2
w._rows["d3"] = r3
running, ok, err = w._summary_counts()
assert (running, ok, err) == (1, 1, 1)
class TestDispatchRow(unittest.TestCase):
"""_DispatchRow state transitions."""
def test_construction_sets_running(self):
from EvoScientist.cli.widgets.panel_widget import _DispatchRow
row = _DispatchRow("idea-brainstorm", "innovator voice")
assert row._subagent_type == "idea-brainstorm"
assert row._label == "innovator voice"
assert row._status == "running"
assert row._duration_ms is None
def test_elapsed_display_uses_recorded_duration(self):
from EvoScientist.cli.widgets.panel_widget import _DispatchRow
row = _DispatchRow("x", "y")
row._duration_ms = 2500
display = row._elapsed_display()
assert "2.5" in display
class TestPanelWidgetReentry:
"""Regression: retry-failed-subset under the same eval_id re-arms the panel.
The ``Promise.allSettled`` → retry-failed-subset pattern (encouraged by
the DELEGATION_STRATEGY guardrail) can send a second wave of
``panel_dispatch_start`` events under the same ``eval_id`` after the
panel has already finalized. ``start_dispatch`` must detect the
finalized state and undo the three effects of ``_maybe_finalize``
(latch, ``--completed`` class, interval timer) so the new row's
spinner + elapsed keep ticking and the header refreshes on completion.
"""
async def _boot_panel(self):
from textual.app import App, ComposeResult
from EvoScientist.cli.widgets.panel_widget import PanelWidget
class _PanelApp(App[None]):
def compose(self) -> ComposeResult:
yield PanelWidget("eval-42")
app = _PanelApp()
return app
def _header_text(self, panel) -> str:
from textual.widgets import Static
header = panel.query_one(".panel-header", Static)
if header.size.height == 0:
return ""
return "".join(seg.text for seg in header.render_line(0)).strip()
async def test_reentry_after_finalize_rearms_panel(self):
from EvoScientist.cli.widgets.panel_widget import PanelWidget
app = await self._boot_panel()
async with app.run_test(size=(120, 20)) as pilot:
panel = app.query_one(PanelWidget)
await pilot.pause()
# Wave 1: two dispatches, one settles ok, one settles err.
await panel.start_dispatch("d1", "innovator", "brainstorm a")
await panel.start_dispatch("d2", "pragmatist", "brainstorm b")
await pilot.pause()
panel.complete_dispatch("d1", 1200)
panel.fail_dispatch("d2", 800, "boom")
await pilot.pause()
# Baseline: finalize fired.
assert panel._is_active is False
assert panel._timer_handle is None
assert panel.has_class("--completed")
header_after_wave1 = self._header_text(panel)
assert "1 done" in header_after_wave1
assert "1 failed" in header_after_wave1
assert "running" not in header_after_wave1
# Wave 2: eval retries the failed subset under the same eval_id.
await panel.start_dispatch("d3", "pragmatist", "brainstorm b retry")
await pilot.pause()
# Panel re-armed: latch flipped, timer restarted, class dropped.
assert panel._is_active is True
assert panel._timer_handle is not None
assert not panel.has_class("--completed")
# Header shows the running count again.
header_during_wave2 = self._header_text(panel)
assert "1 running" in header_during_wave2
assert "1 done" in header_during_wave2
assert "1 failed" in header_during_wave2
# d3 completes: header refreshes to 2 done, panel re-finalizes.
panel.complete_dispatch("d3", 900)
await pilot.pause()
header_after_wave2 = self._header_text(panel)
assert "2 done" in header_after_wave2
assert "1 failed" in header_after_wave2
assert "running" not in header_after_wave2
assert panel._is_active is False
assert panel._timer_handle is None
assert panel.has_class("--completed")
class TestPanelWidgetFinalizeEmpty:
"""Regression: ``finalize_running`` stops the timer even with zero
running rows.
The timer is armed by ``on_mount`` (first-dispatch case) or re-armed
by ``start_dispatch`` (retry-failed-subset case) before any row lands
in the "running" state. If cancel arrives before the first row is
registered, ``finalize_running``'s loop body never runs, so
``_maybe_finalize`` must be called unconditionally after the loop.
"""
async def test_finalize_running_stops_timer_with_no_rows(self):
from textual.app import App, ComposeResult
from EvoScientist.cli.widgets.panel_widget import PanelWidget
class _PanelApp(App[None]):
def compose(self) -> ComposeResult:
yield PanelWidget("eval-empty")
app = _PanelApp()
async with app.run_test(size=(120, 20)) as pilot:
panel = app.query_one(PanelWidget)
await pilot.pause()
# Timer armed by on_mount, no dispatches yet.
assert panel._is_active is True
assert panel._timer_handle is not None
assert panel._rows == {}
panel.finalize_running()
assert panel._is_active is False
assert panel._timer_handle is None
assert panel.has_class("--completed")