ouroboros/tests/test_notifications_browser.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

294 lines
13 KiB
Python

"""Notifications through the real SPA, driven by real socket frames.
What this proves: the shipped client, mounted by the real `app.js`, decides on
frames delivered through the real `web/modules/ws.js` dispatch — one
notification per logical event, none from a reload, hidden text by default, a
child's conclusion kept away from the owner, and a click that actually moves the
application to the source. The socket is a stub only as a TRANSPORT: frames
enter the same `ws.on` path the server's frames take, so nothing here bypasses
the client's own routing. An earlier version of this file called the module
directly and therefore could not see that the subscription was wired inside a
room; it now goes through the dispatch.
What it deliberately does NOT prove: that the OS displayed a banner. A real
system notification is not observable from the page, so `window.Notification`
is a recording stub; the OS surface, its sound and Do Not Disturb stay outside
any automated claim.
"""
from __future__ import annotations
import json
import pytest
from tests.test_subscription_setup_browser import capture, subscription_ui as _subscription_ui
subscription_ui = _subscription_ui # re-exported pytest fixture (requested by name below)
pytestmark = [pytest.mark.ui_browser, pytest.mark.serial]
# Records notifications, and replaces the socket transport with one this test
# can push frames into. `ws.js` keeps its own dispatch, reconnect and queue.
CLIENT_STUB = """
window.__notifications = [];
class RecordingNotification {
static permission = 'granted';
static requestPermission() { return Promise.resolve('granted'); }
constructor(title, options) {
this.title = title;
this.options = options || {};
window.__notifications.push({ title, options: this.options, node: this });
}
close() { this.closed = true; }
}
window.Notification = RecordingNotification;
window.__sockets = [];
class TestSocket {
constructor(url) {
this.url = url;
this.readyState = 1;
window.__sockets.push(this);
setTimeout(() => this.onopen && this.onopen({}), 0);
}
send() {}
close() { this.readyState = 3; if (this.onclose) this.onclose({ code: 1000 }); }
}
TestSocket.OPEN = 1;
window.WebSocket = TestSocket;
window.__deliver = (frame) => {
const socket = window.__sockets[window.__sockets.length - 1];
socket.onmessage({ data: JSON.stringify(frame) });
};
"""
CATEGORY_ENABLED = "() => !document.querySelector('[data-notify-pref=task_done]').disabled"
SHOW_TEXT_SAVED = (
"() => JSON.parse(localStorage.getItem('ouroboros.notifications') || '{}').show_text === true"
)
SOCKET_READY = "() => (window.__sockets || []).length > 0"
MAIN = 1
PROJECT = 4242
def open_notifications(page):
page.locator('[data-nav-page="settings"]').click()
page.wait_for_selector('#page-settings.active')
page.locator('[data-settings-tab="appearance"]').click()
page.wait_for_selector('[data-settings-panel="appearance"].active')
return page.locator('[data-settings-panel="appearance"] [data-notify-settings]')
def pref(page, key):
return page.locator(f'[data-settings-panel="appearance"] [data-notify-pref="{key}"]')
def boot(page, ui):
page.add_init_script(CLIENT_STUB)
page.goto(ui['url'])
page.wait_for_selector('#chat-input')
page.wait_for_function(SOCKET_READY)
def deliver(page, frame):
page.evaluate('frame => window.__deliver(frame)', frame)
page.wait_for_timeout(60)
def deliver_together(page, frames):
"""Dispatch several frames without yielding between them.
`projects_changed` extends the client's room set SYNCHRONOUSLY and then
kicks off an async refresh that replaces it from the server. Delivering the
room announcement and the room's own frame together is how the client
itself behaves when a new Project speaks immediately.
"""
page.evaluate('frames => frames.forEach((frame) => window.__deliver(frame))', frames)
page.wait_for_timeout(60)
def recorded(page):
return page.evaluate('window.__notifications.map(n => ({ title: n.title, options: n.options }))')
def titles(page):
return [note['title'] for note in recorded(page)]
def enable(page):
open_notifications(page)
pref(page, 'enabled').check()
page.wait_for_function(CATEGORY_ENABLED)
def test_notifications_are_off_until_asked_and_then_ring_once(subscription_ui):
ui = subscription_ui
page = ui['page']
boot(page, ui)
# A new client is quiet: nothing is requested from this system.
block = open_notifications(page)
assert block.count() == 1
assert pref(page, 'enabled').is_checked() is False
assert pref(page, 'main_reply').is_checked() is False, 'ordinary Main replies start off'
assert pref(page, 'show_text').is_checked() is False, 'message text starts hidden'
assert pref(page, 'task_done').is_disabled() is True
status = page.locator('[data-settings-panel="appearance"] [data-notify-status]')
assert 'off' in status.inner_text().lower()
capture(page, 'notifications-default-off')
# A real terminal frame while disabled must not ring.
deliver(page, {'type': 'log', 'chat_id': MAIN,
'data': {'type': 'task_done', 'task_id': 't-off', 'status': 'completed'}})
assert recorded(page) == []
pref(page, 'enabled').check()
page.wait_for_function(CATEGORY_ENABLED)
assert 'enabled' in status.inner_text().lower()
capture(page, 'notifications-enabled')
# The test button is the owner's only honest way to see what this system
# does with a notification, so it must become usable and actually deliver.
test_button = page.locator('[data-settings-panel="appearance"] [data-notify-test]')
assert test_button.is_enabled() is True
test_button.click()
assert titles(page) == ['Ouroboros notifications are working']
page.evaluate('window.__notifications.length = 0')
# The shape a finished MANAGED root actually arrives in, then the authored
# summary of the SAME task: one conclusion, one notification.
deliver(page, {'type': 'log', 'chat_id': MAIN,
'data': {'type': 'task_done', 'task_id': 't-1', 'status': 'completed'}})
assert titles(page) == ['Task finished']
deliver(page, {'type': 'chat', 'role': 'system', 'system_type': 'task_summary',
'task_id': 't-1', 'chat_id': MAIN, 'content': 'The report is ready.'})
assert titles(page) == ['Task finished'], 'the same conclusion must not ring twice'
# Progress is not an event.
deliver(page, {'type': 'chat', 'role': 'assistant', 'is_progress': True,
'chat_id': MAIN, 'content': 'working'})
assert len(recorded(page)) == 1
# A conversation turn ends with the same frame a managed task does. With
# the ordinary-reply toggle off, that ending must stay silent rather than
# claiming a task finished.
deliver(page, {'type': 'chat', 'role': 'assistant', 'chat_id': MAIN,
'content': 'Here it is.', 'client_message_id': 'cm-direct'})
deliver(page, {'type': 'log', 'chat_id': MAIN,
'data': {'type': 'task_done', 'task_id': 't-direct',
'status': 'completed', '_is_direct_chat': True}})
assert len(recorded(page)) == 1, 'an ordinary reply must not ring as a finished task'
def test_a_child_conclusion_stays_with_its_parent(subscription_ui):
ui = subscription_ui
page = ui['page']
boot(page, ui)
enable(page)
# A subagent's ordinary traffic declares the lineage its terminal omits.
deliver(page, {'type': 'chat', 'role': 'assistant', 'is_progress': True, 'chat_id': MAIN,
'delegation_role': 'subagent', 'parent_task_id': 'root-1',
'subagent_task_id': 'child-1', 'content': 'child working'})
deliver(page, {'type': 'log', 'chat_id': MAIN,
'data': {'type': 'task_done', 'task_id': 'child-1', 'status': 'completed'}})
assert recorded(page) == [], 'a child escalates to its parent; it does not ring'
deliver(page, {'type': 'log', 'chat_id': MAIN,
'data': {'type': 'task_done', 'task_id': 'root-1', 'status': 'completed'}})
assert titles(page) == ['Task finished']
def test_an_unopened_project_room_notifies_and_machine_traffic_does_not(subscription_ui):
"""The room gate is app.js's own project set, and no panel is involved.
The unit suite proves `attach()` itself for a room with no chat instance
(web/tests/notifications_attach.test.js); this proves the app-level wiring:
the gate app.js passes really does admit a Project room the owner never
opened, and really does refuse machine partitions.
"""
ui = subscription_ui
page = ui['page']
boot(page, ui)
enable(page)
assert page.locator('#project-panel.open').count() == 0
deliver_together(page, [
{'type': 'projects_changed', 'chat_id': PROJECT},
{'type': 'quiz', 'task_id': 't-p', 'chat_id': PROJECT,
'quiz': {'quiz_id': 'q-p', 'state': 'open', 'wait_for_answer': True,
'question': 'Merge the pull request?'}},
])
assert titles(page) == ['Ouroboros is waiting for your answer']
capture(page, 'notifications-unopened-project')
# An answered question never rings, even if its frame arrives late.
deliver(page, {'type': 'quiz', 'task_id': 't-p2', 'chat_id': MAIN,
'quiz': {'quiz_id': 'q-p2', 'state': 'answered', 'wait_for_answer': True}})
assert len(recorded(page)) == 1
# Machine traffic is not the owner's business: the hidden partition and A2A
# ids are refused. An ordinary positive chat this client has not registered
# as a Project IS the owner's, exactly as the Main thread reads it.
for chat_id in (0, -7):
deliver(page, {'type': 'log', 'chat_id': chat_id,
'data': {'type': 'task_done', 'task_id': f'x{chat_id}',
'status': 'completed'}})
assert len(recorded(page)) == 1
deliver(page, {'type': 'log', 'chat_id': 994321,
'data': {'type': 'task_done', 'task_id': 'external', 'status': 'completed'}})
assert len(recorded(page)) == 2, 'an external owner transport still reaches the owner'
# An update/restart teardown reports interrupted and requeues the task: not
# an ending, and it must not consume that task's key.
deliver(page, {'type': 'log', 'chat_id': MAIN,
'data': {'type': 'task_done', 'task_id': 't-requeued',
'status': 'interrupted'}})
assert len(recorded(page)) == 2
deliver(page, {'type': 'log', 'chat_id': MAIN,
'data': {'type': 'task_done', 'task_id': 't-requeued',
'status': 'completed'}})
assert len(recorded(page)) == 3, 'the real completion of that task still rings'
# Click-to-source: the notifier hands the target to app.js's navigation.
assert page.locator('#page-settings.active').count() == 1
page.evaluate('window.__notifications.at(-1).node.onclick()')
page.wait_for_selector('#page-chat.active')
capture(page, 'notifications-click-to-source')
def test_choices_survive_reload_and_reload_itself_never_notifies(subscription_ui):
ui = subscription_ui
page = ui['page']
# A reload replays history; seed that history so the replay has real rows
# to render, including a finished task and an answered question.
page.route('**/api/chat/history*', lambda route: route.fulfill(
content_type='application/json',
body=json.dumps({'messages': [
{'role': 'user', 'text': 'Run the report', 'ts': '2026-09-19T10:00:00Z'},
{'role': 'assistant', 'text': 'Done.', 'ts': '2026-09-19T10:00:01Z', 'task_id': 't-hist'},
{'role': 'system', 'system_type': 'task_summary', 'task_id': 't-hist',
'text': 'Task finished', 'ts': '2026-09-19T10:00:02Z'},
], 'progress': []})))
boot(page, ui)
enable(page)
pref(page, 'show_text').check()
page.wait_for_function(SHOW_TEXT_SAVED)
page.reload()
page.wait_for_selector('#chat-input')
page.wait_for_function(SOCKET_READY)
page.wait_for_timeout(300)
assert recorded(page) == [], 'rendering history must stay silent'
open_notifications(page)
assert pref(page, 'enabled').is_checked() is True, 'the choice is client-local and persisted'
assert pref(page, 'show_text').is_checked() is True
# With text shown, the body carries the message.
deliver(page, {'type': 'chat', 'role': 'system', 'system_type': 'task_summary',
'task_id': 't-after-reload', 'chat_id': MAIN, 'content': 'The report is ready.'})
notes = recorded(page)
assert len(notes) == 1
assert notes[0]['options']['body'] == 'The report is ready.'
capture(page, 'notifications-after-reload')