ouroboros/tests/test_subscription_wait_integration_browser.py
Anton Razzhigaev f6711d3369 feat: integrate subscription accounts into model setup and execution
Add caller-owned Claudexor model calls, unified subscription/API setup, explicit role accounts and live quota/auth continuation. Preserve prompt/tool ownership, physical-attempt custody and manual assignments. Keep the existing task process through waits and post-work.

The reviewed source checkpoint retains the current dependency pin. Published signed Claudexor bytes, cross-platform CI and the agreed merge ordering remain delivery prerequisites. No Ouroboros version, tag or release is created.

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
2026-09-07 10:54:51 +00:00

341 lines
18 KiB
Python

"""Browser actions through real wait ingress, mailbox, persistence and LLM continuation.
The task is represented by isolated RUNNING metadata, not a supervisor worker
process. LLMClient, TaskModelWait, the physical ledger, decision endpoint,
supervisor wait projection, history and settings writer are real. Only provider
replies and the browser's unrelated bootstrap APIs are controlled fixtures.
"""
from __future__ import annotations
import copy
import json
import queue
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from types import SimpleNamespace
from urllib.parse import urlparse
import httpx
import pytest
from openai import OpenAI
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.testclient import TestClient
from tests import test_llm_claudexor as llm_fixture
from tests import test_model_wait as wait_fixture
from tests import test_subscription_setup_browser as browser_fixture
subscription_ui = browser_fixture.subscription_ui
setup = llm_fixture.setup
live_wait = wait_fixture.live_wait
pytestmark = [pytest.mark.ui_browser, pytest.mark.serial]
TASK = "task-one"
MODEL = llm_fixture.MODEL
@pytest.fixture
def integrated_wait(subscription_ui, live_wait, monkeypatch):
from ouroboros import config, model_wait, pricing, usage_accounting as accounting
from ouroboros.gateway.history import make_chat_history_endpoint
from ouroboros.gateway.task_decision import api_decision_answer
from ouroboros.gateway.tasks import api_task_get
from ouroboros.task_results import load_task_result, write_task_result
from ouroboros.utils import append_jsonl
from supervisor import queue as task_queue
from supervisor.task_model_wait import handle_task_model_wait
ui = subscription_ui
page = ui["page"]
root, gateway, llm, controller, events, _unused_direct_decide = live_wait
controller.task["chat_id"] = 1
monkeypatch.setattr(config, "SETTINGS_PATH", root / "settings.json")
monkeypatch.setattr(config, "CLAUDEXOR_MODEL_POLL_INTERVAL_SEC", 0.01)
monkeypatch.setattr(model_wait, "time", time)
monkeypatch.setattr(pricing, "_fetch_live_rows", lambda *_: [])
monkeypatch.setenv("OPENAI_API_KEY", "fixture-not-a-live-key")
monkeypatch.setenv("OUROBOROS_MODEL_ACCOUNTS", json.dumps({"main": "personal", "light": "work"}))
settings = {"OUROBOROS_MODEL": MODEL, "OUROBOROS_MODEL_LIGHT": MODEL,
"OUROBOROS_MODEL_ACCOUNTS": json.dumps({"main": "personal", "light": "work"}),
"OUROBOROS_CONTEXT_MODE": "max", "OUROBOROS_RUNTIME_MODE": "advanced"}
root.mkdir(parents=True, exist_ok=True)
config.SETTINGS_PATH.write_text(json.dumps(settings), encoding="utf-8")
original_settings = config.SETTINGS_PATH.read_bytes()
ui["settings"].update(settings)
source_entered, source_release = threading.Event(), threading.Event()
catalog_entered, catalog_release = threading.Event(), threading.Event()
def sources():
source_entered.set()
if not source_release.wait(20):
raise AssertionError("test did not release source metadata")
return {"sources": [{"id": "codex", "credentialHarness": "codex"}]}
def catalog(*_, **_kw):
catalog_entered.set()
if not catalog_release.wait(20):
raise AssertionError("test did not release the unavailable catalog")
raise llm_fixture.ClaudexorUnavailable("subscription_window_exhausted", "controlled quota remains exhausted")
monkeypatch.setattr(llm, "claudexor_model_sources", sources)
monkeypatch.setattr(llm, "claudexor_model_catalog", catalog)
def served(account):
return {**llm_fixture.ROUTE, "credentialProfileId": account, "accountFingerprint": f"fixture-{account}"}
main_reply = llm_fixture.result(route=served("personal"))
review_reply = llm_fixture.result(route=served("personal"))
review_reply["message"] = {"role": "assistant", "content": "Completed independent review."}
refusal = llm_fixture.result(outcome="failed", route=served("work"), problem={
"code": "subscription_window_exhausted", "message": "controlled quota exhausted",
"context": {"resetsAt": "2099-01-01T00:00:00Z"},
})
gateway.results = [main_reply, review_reply, refusal]
gateway.dispatch = ["response_received", "response_received", "not_started"]
completed = []
main, _ = llm.chat([{"role": "user", "content": "Read two files."}], MODEL, model_role="main")
tools = []
for index, tool in enumerate(main["tool_calls"], 1):
# Deterministic fixture tools really write once; the waiter must resume
# the pending LLM call, not restart this already-completed work.
artifact = root / f"completed-tool-{index}.txt"
artifact.write_text(f"tool result {index}", encoding="utf-8")
completed.append(tool["id"])
tools.append({"role": "tool", "tool_call_id": tool["id"], "content": artifact.read_text()})
review, _ = llm.chat([{"role": "user", "content": "Review completed work."}], MODEL,
model_role="reviewer:finished", model_account_override="personal")
completed.append("review")
messages = [{"role": "user", "content": "Summarize completed work."}, main, *tools,
{"role": "user", "content": review["content"]}]
original_messages = copy.deepcopy(messages)
api_requests = []
def api_provider(request):
payload = json.loads(request.content)
api_requests.append(payload)
return httpx.Response(200, json={
"id": "fixture-api-generation", "object": "chat.completion", "created": 1,
"model": payload["model"], "choices": [{"index": 0, "finish_reason": "stop",
"message": {"role": "assistant", "content": "Continued exactly once."}}],
"usage": {"prompt_tokens": 40, "completion_tokens": 6, "total_tokens": 46},
})
http_client = httpx.Client(transport=httpx.MockTransport(api_provider))
sdk = OpenAI(api_key="fixture-not-a-live-key", base_url="http://provider.invalid/v1",
http_client=http_client, max_retries=0)
monkeypatch.setattr(llm, "_get_remote_client", lambda _target: sdk)
sockets, decisions, responses, published = [], [], [], []
page.route_web_socket("**/ws", lambda socket: sockets.append(socket))
app = Starlette(routes=[
Route("/api/decisions", api_decision_answer, methods=["POST"]),
Route("/api/tasks/{task_id}", api_task_get),
Route("/api/chat/history", make_chat_history_endpoint(root)),
])
app.state.drive_root = root
projection_context = SimpleNamespace(RUNNING=task_queue.RUNNING, DRIVE_ROOT=root,
append_jsonl=append_jsonl, bridge=SimpleNamespace(push_log=published.append))
def project(event):
before = len(published)
handle_task_model_wait(event, projection_context)
if sockets and len(published) > before:
sockets[-1].send(json.dumps({"type": "log", "chat_id": 1, "data": published[-1]}))
def pump(predicate):
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
try:
event = events.get(timeout=max(0.01, deadline - time.monotonic()))
except queue.Empty:
break
project(event)
if predicate(event):
return event
raise AssertionError("expected real TaskModelWait event was not published")
def pending_call():
with accounting.usage_scope(accounting.UsageScope(
drive_root=root, task_id=TASK, root_task_id=TASK)):
# A never-dispatched quota refusal releases its claim. Exactly
# one resumed provider generation can use this existing limiter.
with accounting.physical_attempt_limit(1):
return llm.chat(messages, MODEL, model_role="light")
def finish(answer):
result = write_task_result(root, TASK, "completed", result=answer["content"])
# The activity envelope belongs to this process-free fixture. Ending
# it must remove its RUNNING metadata as an actual worker finish does.
with task_queue._queue_lock:
task_queue.RUNNING.pop(TASK, None)
return result
pool = ThreadPoolExecutor(max_workers=1, thread_name_prefix="subscription-wait-acceptance")
future = pool.submit(model_wait.copy_wait_context().run, pending_call)
try:
assert source_entered.wait(5)
first = pump(lambda event: event["state"] == "waiting")
assert first["role"] == "light" and first["credential_profile_id"] == "work"
with TestClient(app) as client:
def forward(route):
request = route.request
parsed = urlparse(request.url)
path = parsed.path + (f"?{parsed.query}" if parsed.query else "")
if request.method == "POST":
body = request.post_data_json
decisions.append(body)
response = client.post(path, json=body)
responses.append({"status": response.status_code, "body": response.json()})
else:
response = client.get(path)
route.fulfill(status=response.status_code, content_type="application/json", body=response.content)
page.route("**/api/decisions", forward)
page.route(f"**/api/tasks/{TASK}", forward)
page.route("**/api/chat/history*", forward)
def state_response(route):
# Only activity envelope is fixture-owned. Its wait projection
# is the real supervisor handler's current queue metadata.
task = task_queue.RUNNING.get(TASK, {}).get("task", {})
route.fulfill(content_type="application/json", body=json.dumps({
"supervisor_ready": True, "projects": [], "active_chat_activities": [{
"activity_id": TASK, "kind": "managed_task", "chat_id": 1,
"phase": "working", "model_waits": task.get("model_waits", {}),
}] if task else [],
}))
page.route("**/api/state", state_response)
page.goto(ui["url"] + "/")
page.wait_for_selector(f'[data-wait-id="{first["wait_id"]}"]')
yield SimpleNamespace(ui=ui, page=page, root=root, controller=controller, gateway=gateway,
client=client, wait_id=first["wait_id"], first=first, source_release=source_release,
catalog_entered=catalog_entered, catalog_release=catalog_release,
pump=pump, decisions=decisions, responses=responses, future=future,
api_requests=api_requests, completed=completed, messages=messages,
original_messages=original_messages, original_settings=original_settings,
canonical=lambda: load_task_result(root, TASK), settings_path=config.SETTINGS_PATH,
finish=finish)
finally:
controller.close()
source_release.set()
catalog_release.set()
pool.shutdown(wait=True, cancel_futures=True)
sdk.close()
http_client.close()
@pytest.mark.parametrize("persist_role", [False, True])
def test_browser_wait_controls_apply_once_and_only_to_light(integrated_wait, persist_role):
flow = integrated_wait
page = flow.page
row = page.locator(f'[data-wait-id="{flow.wait_id}"]')
card = page.locator(f'.chat-live-card[data-task-id="{TASK}"]')
assert page.locator(".model-wait-row").count() == 1
assert "Light" in row.inner_text() and "Account: work" in row.inner_text()
assert row.locator("[data-wait-auto]").is_checked()
assert not card.locator("[data-live-typing]").is_visible()
assert set(flow.canonical()["model_waits"]) == {flow.wait_id}
# Let real source metadata publish a newer revision without delivering
# that WS event yet. The stale browser action must traverse the real 409.
flow.source_release.set()
assert flow.catalog_entered.wait(5)
latest = flow.canonical()["model_waits"][flow.wait_id]
assert latest["revision"] > flow.first["revision"]
row.locator("[data-wait-auto]").uncheck()
page.wait_for_function("() => document.querySelector('[data-wait-notice]').textContent.includes('stale_model_wait')")
assert flow.responses[-1]["status"] == 409
assert flow.responses[-1]["body"]["reason_code"] == "stale_model_wait"
assert "pending_action" not in flow.canonical()["model_waits"][flow.wait_id]
assert not (flow.root / "memory/owner_mailbox" / f"{TASK}.jsonl").exists()
assert row.locator("[data-wait-auto]").is_checked()
row.locator("[data-wait-auto]").uncheck()
page.wait_for_function("() => document.querySelector('[data-wait-notice]').textContent.includes('Request accepted')")
toggle = flow.decisions[-1]
assert toggle["revision"] == latest["revision"] and toggle["action"] == "auto_continue"
canonical = flow.canonical()["model_waits"][flow.wait_id]
assert canonical["auto_continue"] is True
assert canonical["pending_action"]["request_id"] == toggle["request_id"]
assert flow.responses[-1]["status"] == 202 and flow.responses[-1]["body"]["applied"] is False
browser_fixture.capture(page, f"integrated-wait-accepted-{persist_role}")
flow.catalog_release.set()
applied = flow.pump(lambda event: event.get("applied_request_id") == toggle["request_id"])
assert applied["auto_continue"] is False and applied["revision"] > toggle["revision"]
page.wait_for_selector("[data-wait-change]:not([disabled])")
assert not row.locator("[data-wait-auto]").is_checked()
row.locator("[data-wait-change]").click()
row.locator("[data-model-role-source]").select_option("openai")
row.locator("[data-model-role-model]").fill("owner-model")
assert not row.locator("[data-wait-persist]").is_checked()
if persist_role:
row.locator("[data-wait-persist]").check()
# Hold only the real worker's drain lock to expose accepted != applied.
# The real endpoint still validates, locks, persists and writes the mailbox.
with flow.controller.lock:
row.locator("[data-wait-apply]").click()
page.wait_for_function("() => document.querySelector('[data-wait-notice]').textContent.includes('Request accepted')")
command = flow.decisions[-1]
assert command["persist_role"] is persist_role
assert command["model"] == "openai::owner-model" and command["credential_profile_id"] == ""
assert flow.responses[-1]["status"] == 202 and flow.responses[-1]["body"]["applied"] is False
assert flow.responses[-1]["body"]["saved"] is persist_role
assert flow.canonical()["model_waits"][flow.wait_id]["state"] == "waiting"
assert page.locator(".model-wait-row").count() == 1
if persist_role:
saved = json.loads(flow.settings_path.read_text())
assert saved["OUROBOROS_MODEL"] == MODEL
assert saved["OUROBOROS_MODEL_LIGHT"] == "openai::owner-model"
accounts = saved["OUROBOROS_MODEL_ACCOUNTS"]
if isinstance(accounts, str):
accounts = json.loads(accounts)
assert accounts["main"] == "personal" and accounts["light"] == ""
else:
assert flow.settings_path.read_bytes() == flow.original_settings
browser_fixture.capture(page, f"integrated-wait-switch-{persist_role}")
resolved = flow.pump(lambda event: event["state"] == "resolved")
assert resolved["resolution"] == "model_switched"
assert resolved["applied_request_id"] == command["request_id"]
page.wait_for_selector(f'[data-wait-id="{flow.wait_id}"]', state="detached")
answer, usage = flow.future.result(timeout=5)
assert answer["content"] == "Continued exactly once."
assert set(flow.controller.overrides) == {"light"}
assert usage["model_role_route"]["role"] == "light"
assert len(flow.api_requests) == 1 and flow.api_requests[0]["model"] == "owner-model"
assert [entry[0]["account"] for entry in flow.gateway.uploads] == [
{"mode": "pin", "profileId": "personal"}, {"mode": "pin", "profileId": "personal"},
{"mode": "pin", "profileId": "work"}]
assert flow.completed == ["a", "b", "review"]
assert flow.messages == flow.original_messages
sent_tools = [message for message in flow.api_requests[0]["messages"] if message["role"] == "tool"]
assert sent_tools == [message for message in flow.messages if message["role"] == "tool"]
assert any(message.get("content") == "Completed independent review."
for message in flow.api_requests[0]["messages"])
ledger = llm_fixture.ledger(flow.root)
assert [entry["state"] for entry in ledger].count("settled") == 3
assert [entry["state"] for entry in ledger].count("released") == 1
assert len(usage["ledger_attempt_ids"]) == 2
# A retry of the already-applied action reaches the real idempotency record;
# neither a second generation nor another settings rewrite is allowed.
stamp = flow.settings_path.stat().st_mtime_ns
replay = flow.client.post("/api/decisions", json=command)
assert replay.status_code == 200 and replay.json()["duplicate"] and replay.json()["applied"]
assert flow.settings_path.stat().st_mtime_ns == stamp and len(flow.api_requests) == 1
flow.finish(answer)
actual = flow.client.get(f"/api/tasks/{TASK}").json()
assert actual["status"] == "completed"
assert actual["model_waits"][flow.wait_id]["applied_request_id"] == command["request_id"]
history = flow.client.get("/api/chat/history").json()
waits = [message for message in history["messages"] if message.get("system_type") == "task_model_wait"]
assert waits and all(message["task_terminal_status"] == "completed" for message in waits)
with page.expect_response(lambda response: urlparse(response.url).path == "/api/chat/history") as loaded:
page.reload()
hydrated = loaded.value.json()
assert any(message.get("task_terminal_status") == "completed" for message in hydrated["messages"])
# This fixture never emitted a task_summary, so terminal wait references
# may correctly disappear altogether instead of inventing a live card.
page.evaluate("() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))")
assert page.locator(".model-wait-row").count() == 0
browser_fixture.capture(page, f"integrated-wait-resolved-{persist_role}")