ouroboros/tests/test_ui_smoke_task_card_recovery.py
Ouroboros 148551ff2a Key the chrome on work, not on any content: no attribute, no compact CSS
Roast round 1 (three lanes): the content predicate counted a bare non-Done
ending as content, so a greeting that failed with no tool call would have
been offered "Turn into project" — outside the owner's rule that a turn
without tools gets no card with actions. blockHasWork now names the work a
block stands on (an always-shown kind, a review group, a child, a non-receipt
row, a tool error); presence keeps the non-Done term. The data-chrome
attribute and its three CSS rules are gone: every attention-only state already
shows its own chip (Waiting…, Cancelling…, Failed), so the only thing a block
without work lacks is a title placeholder and the conversion control — one
unconditional rule hides the empty title.

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
2026-09-16 02:51:35 +03:00

180 lines
9.1 KiB
Python

"""Project task-card facts survive real browser hydration and panel recreation."""
from __future__ import annotations
import json
import os
from pathlib import Path
import pytest
from tests.test_ui_smoke_playwright import direct_server_with_data # noqa: F401
@pytest.mark.serial
@pytest.mark.ui_browser
@pytest.mark.parametrize("browser_engine", ["chromium", "webkit"])
def test_bound_direct_task_header_and_review_cost_survive_reopen(
direct_server_with_data, browser_engine, monkeypatch, # noqa: F811
):
"""Exercise Project open/reopen, reference metadata and durable completion.
The real server owns Project registration, binding, history and task detail.
Its activity fragment comes from the production census builder with an
explicit direct-registry fixture, so no model task or paid call is needed.
"""
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
import supervisor.queue as queue_mod
from ouroboros.gateway.state import _chat_activities_snapshot_safe
from ouroboros.projects_registry import bind_task_to_project, create_project
from ouroboros.task_results import STATUS_COMPLETED, STATUS_RUNNING, write_task_result
url = direct_server_with_data["url"]
data_dir = direct_server_with_data["data_dir"]
project_id, task_id = "card-recovery", "card-recovery-root"
project = create_project(data_dir, project_id, name="Context anatomy")
chat_id = int(project["chat_id"])
bind_task_to_project(data_dir, task_id, project_id, chat_id, origin={"absent": "system"})
write_task_result(
data_dir, task_id, STATUS_RUNNING, chat_id=1, project_id="",
suggested_name="Analyze greeting context", _is_direct_chat=True,
)
progress = data_dir / "logs" / "progress.jsonl"
progress.parent.mkdir(parents=True, exist_ok=True)
rows = [{
"task_id": task_id, "chat_id": 1, "direction": "out", "is_progress": True,
"content": text, "ts": f"2026-09-15T11:05:0{index}+00:00", "cancelable": True,
} for index, text in enumerate([
"Reading the context sources.",
"Task acceptance review is running; Main can receive and answer messages.",
])]
reference = {
"type": "review_reference", "task_id": task_id, "chat_id": 1,
"presentation_owner_task_id": task_id, "surface": "task_acceptance",
"state_revision": "a" * 64, "is_progress": True,
"ts": "2026-09-15T11:05:03+00:00",
"cost_accounting_status": "available", "cost_final": False,
"accounted_upper_bound_usd_with_children": 2.75,
}
progress.write_text("".join(json.dumps(row) + "\n" for row in [*rows, reference]), encoding="utf-8")
# No live global queue or registry is consulted by the fixture fragment.
monkeypatch.setattr(queue_mod, "PENDING", [])
monkeypatch.setattr(queue_mod, "RUNNING", {})
monkeypatch.setattr(queue_mod, "BUDGET_ROOT_FENCES", {})
active = {"running": True}
original = {
"activity_id": task_id, "chat_id": 1, "project_id": "", "kind": "direct_chat",
"phase": "thinking", "client_message_id": "fixture-owner-message", "started_at": 1.0,
}
evidence = Path(os.environ.get("OUROBOROS_UI_EVIDENCE_DIR", str(data_dir.parent)))
evidence.mkdir(parents=True, exist_ok=True)
card_selector = f'#panel-pchat-{project_id} .chat-live-card[data-task-id="{task_id}"]'
with sync_playwright() as pw:
browser = getattr(pw, browser_engine).launch(headless=True)
page = browser.new_page(viewport={"width": 1280, "height": 900})
try:
def project_activity(route):
response = route.fetch()
payload = response.json()
direct = [dict(original)] if active["running"] else []
availability = {"complete": True}
payload["active_direct_turns"] = direct
payload["active_chat_activities"] = _chat_activities_snapshot_safe(
data_dir, payload["task_bindings"], direct_turns=direct,
availability=availability,
)
payload["active_chat_activities_complete"] = availability["complete"]
route.fulfill(content_type="application/json", body=json.dumps(payload))
page.route("**/api/state", project_activity)
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
project_row = page.locator(f'.nav-project-row[data-project-id="{project_id}"]')
project_row.wait_for(state="visible", timeout=30_000)
def open_project():
project_row.click()
page.locator("#project-panel").wait_for(state="visible")
page.locator(card_selector).wait_for(state="visible", timeout=15_000)
def close_project():
page.locator("#project-panel-close").click()
page.wait_for_function(
"() => !document.getElementById('project-panel').classList.contains('open')"
)
def screenshot(stage):
page.screenshot(path=str(evidence / f"task-card-{browser_engine}-{stage}.png"), animations="disabled")
def assert_running(amount):
card = page.locator(card_selector)
# The subject is a DIRECT turn with narration rows: content, so
# the block wears the task-card chrome (DESIGN.md "Conversation
# activity block", owner decision 16.09) — a visible Working
# chip, the coined title, a running indicator while it runs —
# and, inside a Project panel, no conversion control.
expect(card.locator("[data-live-phase]")).to_have_attribute("data-phase", "working", timeout=10_000)
expect(card.locator("[data-live-phase]")).to_be_visible()
expect(card.locator("[data-live-phase]")).to_have_text("Working")
expect(card.locator("[data-live-title]")).to_have_text("Analyze greeting context")
expect(card.locator("[data-turn-into-project]")).to_have_count(0)
expect(card.locator("[data-live-typing]")).to_be_visible()
expect(card.locator("[data-live-meta]")).not_to_contain_text("Activity unconfirmed")
expect(card.locator("[data-live-meta]")).to_contain_text(f"up to ${amount}")
expect(card).to_have_attribute("data-finished", "0")
open_project()
assert_running("2.75")
screenshot("opened")
count = page.locator(f"{card_selector} [data-live-count]").inner_text()
# Same review revision: metadata must update even when its detail
# hydration is deduplicated. A reference does not add an activity note.
updated = {**reference, "chat_id": chat_id, "ts": "2026-09-15T11:05:04+00:00",
"accounted_upper_bound_usd_with_children": 3.50}
with progress.open("a", encoding="utf-8") as stream:
stream.write(json.dumps(updated) + "\n")
page.evaluate(
"row => window.__ouroWs.emit('log', {chat_id: row.chat_id, data: row})", updated,
)
assert_running("3.50")
expect(page.locator(f"{card_selector} [data-live-count]")).to_have_text(count)
close_project()
open_project()
assert_running("3.50")
screenshot("reopened")
# A lost terminal event must recover from the durable result when
# the direct activity leaves the census; absence alone is not Done.
terminal = write_task_result(
data_dir, task_id, STATUS_COMPLETED, chat_id=1, project_id="",
suggested_name="Analyze greeting context", result="Context analysis completed.",
cost_accounting_status="available", cost_final=True,
accounted_upper_bound_usd_with_children=3.50,
)
active["running"] = False
page.evaluate("() => window.__ouroWs.emit('projects_changed', {})")
card = page.locator(card_selector)
expect(card).to_have_attribute("data-finished", "1", timeout=15_000)
expect(card.locator("[data-live-phase]")).to_have_attribute("data-phase", "done")
expect(card.locator("[data-live-phase]")).to_be_visible()
expect(card.locator("[data-live-phase]")).to_have_text("Done")
expect(card.locator("[data-live-typing]")).not_to_be_visible()
page.evaluate(
"row => window.__ouroWs.emit('log', {chat_id: row.chat_id, data: row})",
{**terminal, "type": "task_done", "chat_id": chat_id},
)
close_project()
open_project()
expect(card).to_have_attribute("data-finished", "1")
expect(card.locator("[data-live-phase]")).to_have_attribute("data-phase", "done")
screenshot("completed")
except Exception:
page.screenshot(path=str(evidence / f"task-card-{browser_engine}-failed.png"), animations="disabled")
raise
finally:
browser.close()