ouroboros/tests/test_notifications_static.py
ouroboros-agent 72c3747acb feat(v7.3.0): notifications pull the owner back to a question or a finished task
While a client runs, Ouroboros can now reach the owner who left the window.
Settings -> Appearance gains a notification block beside the theme: a master
switch, the two required categories (a question or decision is waiting for you,
a task finished or stopped), a model-chosen category for messages Ouroboros
sends while it works, a separate off-by-default toggle for ordinary replies in
Main, sound, an off-by-default show-the-text choice, and a test notification.
The choices are stored per client exactly like the appearance choice, never
reach the server, and are excluded from the settings-dirty tracker so a
client-local toggle never offers to discard unsaved server settings.

Delivery is page-level: a system banner where this client exposes one and
permission is granted, otherwise the in-app surface plus one short tone, with
the status line stating which surface this client actually has. Either way a
click opens the source. No OS permission, Do Not Disturb or platform limit is
bypassed, and no native launcher path is added in this version.

web/modules/notifications.js keeps classification and the delivery gate pure
over one live frame plus the stored preferences, and takes ONE subscription per
client on the shared socket. It is deliberately NOT wired inside a chat
instance: an instance dies with its room, so a Project the owner never opened
would never have rung - the case notifications exist for. web/modules/chat.js is
byte-identical to before. Only live frames reach the notifier, so a reload
cannot re-notify without storing any notification state.

A finished task is recognised on the shape it actually arrives in (the live
task_done log frame) as well as the authored summary, and a conversation turn's
own ending stays the ordinary-reply category instead of claiming a task
finished; all of them collapse to one key per task. A child task never notifies
the owner: it escalates to its parent, and lineage is read from the delegation
facts frames carry because the terminal frame carries none. Importance needs no
new host field and no second model call - the existing proactive-message
discriminator is the signal.

Policy has one canonical home in docs/DESIGN.md section 9, including its
disclosed limits; docs/DEVELOPMENT.md carries the engineering invariant and the
architecture chapter names the owner. Two chapter byte budgets are raised with
stated reasons.

Verification: 1506 node tests; four browser journeys driving real socket frames
through ws.js (defaults off, one notification per event, silence on reload,
child conclusion withheld, unopened Project room reached, machine traffic
refused, click moves to the source); the deterministic repository gates (size
ratchet, generated manifests, README history limit, chapter budgets, docs sync)
pass. Three failures in the full battery are proven pre-existing by running the
same tests on the unmodified base commit.

Review-driven corrections in this commit, each with its own regression: a
`task_done` frame counts as an ending only with a SETTLED status, because an
update or restart teardown reports `interrupted` and requeues the same task id
(ringing there would also have burned that task's key and silenced its real
completion); a bare `task_terminal_status` chat row belongs to a DIRECT turn and
stays the ordinary-reply category instead of claiming a task finished; a child's
chat row is dropped whatever else it carries; the room gate mirrors
`mainThreadAccepts`, so an external owner transport notifies while the hidden
partition and A2A ids never do; and the client-level listener absorbs its own
errors, because it runs before the chat instances' handlers and `ws.emit` does
not isolate them.
2026-09-20 01:33:19 +03:00

116 lines
5.4 KiB
Python

"""Structural guards for notifications: the facts a browser cannot show.
Three claims here are invisible to a Playwright run:
* **Replay safety is structural.** Nothing about past notifications is stored,
so "a reload does not re-notify" rests entirely on the notifier being reached
from live ``onWs`` handlers and from nowhere else. A browser test can show
that one reload was quiet; only the source can show WHY every reload is.
* **The preferences never reach the server.** ``web/modules/settings.js``
collects ``s-``-prefixed fields into the ``/api/settings`` payload, so
"notification choices stay client-local" rests on the Appearance block owning
no such field. The browser suite asserts the consequence; this asserts the
cause.
* **One canonical policy.** DESIGN §9 is the single owner-facing statement and
DEVELOPMENT points at it instead of restating it — a documentation invariant,
not a rendered behaviour.
Pattern follows ``tests/test_appearance_static.py``: read the sources, assert
the structural fact, no browser needed.
"""
from __future__ import annotations
import pathlib
import re
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
def _read(rel: str) -> str:
return (REPO_ROOT / rel).read_text(encoding="utf-8")
def _appearance_panel(source: str) -> str:
start = source.index('data-settings-panel="appearance"')
end = source.index("</section>", start)
return source[start:end]
def test_notification_controls_are_client_local():
panel = _appearance_panel(_read("web/modules/settings_ui.js"))
assert "data-notify-settings" in panel, "the notification block lives under Appearance"
block = panel[panel.index("data-notify-settings"):]
prefs = set(re.findall(r'data-notify-pref="([a-z_]+)"', block))
assert prefs == {
"enabled", "needs_answer", "task_done", "important", "main_reply", "sound", "show_text",
}, prefs
assert "data-notify-test" in block, "a test notification is the honest way to see the surface"
# The cause of "never posted to the server": no settings field at all.
for attribute in ('id="s-', 'name="s-'):
assert attribute not in block, (
f"{attribute} inside the notification block would enter the /api/settings payload"
)
def test_settings_mounts_the_notifier_like_the_theme():
source = _read("web/modules/settings.js")
assert "getNotifier().mountSettings(page)" in source
# A client-local control must not mark the SERVER draft dirty, or leaving
# Settings would ask to discard changes that were never going to be posted.
assert "data-notify-settings" in source
assert "page.addEventListener('change', onServerSettingEdited)" in source
# Client-local surfaces are mounted after the panel is injected, next to the
# appearance choice they share their storage discipline with.
assert source.index("ouroTheme?.mount()") < source.index("mountSettings(page)")
def test_the_subscription_outlives_every_room():
"""The defect this guard exists for: a chat instance dies with its room.
Closing a Project panel calls destroyProjectInstance -> inst.destroy(),
which disposes that instance's ws subscriptions. A notifier wired inside a
chat instance is therefore silent in exactly the case notifications are for
— the owner left, the room is closed. So the subscription must be taken once
at client level, on the shared socket, and chat.js must not be the wiring
point at all.
"""
app = _read("web/app.js")
assert "getNotifier().attach({" in app
attach = app[app.index("getNotifier().attach({"):]
attach = attach[:attach.index("});") + 3]
assert "ws," in attach, "the shared socket, not a room, carries the subscription"
assert "ownerVisibleChat" in attach, "machine traffic must not notify"
assert "projectChatIds" in attach, "a closed Project's frames must still be eligible"
chat = _read("web/modules/chat.js")
assert "notifications.js" not in chat, (
"chat.js must not wire notifications: its handlers are per room and die with it"
)
assert "getNotifier" not in chat
# Only live socket frames reach the notifier; the module takes no history
# reader, so replay safety is structural rather than a stored ledger.
module = _read("web/modules/notifications.js")
for event in ("'chat'", "'quiz'", "'log'"):
assert f"on({event}" in module, f"the {event} frame is a live source"
assert "applyHistoryMessages" not in module and "chat/history" not in module
def test_policy_has_one_canonical_home():
design = _read("docs/DESIGN.md")
assert "## 9. Notifications" in design
assert "ONE canonical statement" in design
development = _read("docs/development/03-module-size-and-complexity.md")
assert "notifications ring for live events only" in development
assert "`docs/DESIGN.md` §9" in development, "DEVELOPMENT points at the policy, never restates it"
architecture = _read("docs/architecture/03-web-ui-pages-and-buttons.md")
assert "web/modules/notifications.js" in architecture
def test_importance_uses_the_existing_discriminator():
source = _read("web/modules/notifications.js")
assert "proactive_message" in source
# No second model call and no new host field: the whole decision is local.
for forbidden in ("fetch(", "apiFetch", "apiClient"):
assert forbidden not in source, f"{forbidden} would make a notification decision remote"