From 6c2d8473b1ac99f6e649d556d441e588054c9a0f Mon Sep 17 00:00:00 2001 From: ThorstenHellert Date: Wed, 12 Aug 2026 12:41:14 +0200 Subject: [PATCH] fix(web): keep human panel focus local to the gesturing client A human tab switch is a report, not a command: the server now mirrors active_panel silently for source-less focus POSTs and broadcasts a panel_focus frame only for agent-attributed switches, so one operator's clicks can no longer rearrange every other window of the same workspace or bounce back as a delayed echo that evicts tiles mid-gesture. The gesturing client applies its own focus locally through a new dock-sync focus-handler seam instead of riding the echo, and a tile close runs its dockview removal inside the echo guard so the survivor auto-activation is never reported as a human gesture. --- CHANGELOG.md | 6 ++ .../interfaces/web_terminal/routes/panels.py | 21 ++-- .../web_terminal/static/js/dock-sync.js | 25 ++++- .../web_terminal/static/js/dock-tab.js | 7 +- .../web_terminal/static/js/panel-manager.js | 25 +++-- .../e2e/web_terminals/test_prefix_routing.py | 7 +- tests/interfaces/web_terminal/test_app.py | 2 +- .../web_terminal/test_panels_browser.py | 8 +- .../test_panels_collab_browser.py | 95 ++++++++++++++++--- .../web_terminal/test_panels_prefix.py | 17 +++- .../web_terminal/test_panels_routes.py | 52 ++++++++-- .../web_terminal/test_panels_source_tag.py | 7 +- 12 files changed, 220 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df4ea6ede..896ce090e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -347,6 +347,12 @@ Compatibility is documented in release notes, not encoded in the version string. ### Fixed +- One operator's tab switches no longer rearrange every other window of the + same workspace: a human panel focus is now mirrored to the server silently + (the agent can still read where the operator is looking) instead of being + broadcast back, whose delayed echo could evict tiles the operator had open — + in the gesturing window and in every other one. Closing a tile no longer + reports its side-effect focus change either. - Web terminal panels no longer freeze permanently — rendering but ignoring every click — when a drag from the panel rail loses its end event (for example the dragged entry was removed mid-drag by the agent or another diff --git a/src/osprey/interfaces/web_terminal/routes/panels.py b/src/osprey/interfaces/web_terminal/routes/panels.py index 75c51393d..0979a67c3 100644 --- a/src/osprey/interfaces/web_terminal/routes/panels.py +++ b/src/osprey/interfaces/web_terminal/routes/panels.py @@ -351,7 +351,15 @@ async def get_panel_focus(request: Request): @router.post("/api/panel-focus") async def set_panel_focus(body: PanelFocusRequest, request: Request): - """Set the active panel and broadcast a focus event via SSE. + """Set the active panel; broadcast a focus event only for agent switches. + + Attribution decides the frame's fate. An ``source: "agent"`` switch is a + command every client must apply, so it broadcasts. A source-less POST is a + human gesture REPORT (panel-commands.js's ``setPanelFocus``): the server + mirrors ``active_panel`` for the agent's gaze and broadcasts nothing — + one operator's tab switches never move another client's workspace, and + the gesturing client applies its own focus locally rather than riding an + echo. ``body.url`` (e.g. from an agent-invoked ``switch_panel`` MCP call) is run through ``_prefix_path()`` before broadcast so a root-absolute path @@ -408,12 +416,11 @@ async def set_panel_focus(body: PanelFocusRequest, request: Request): visibility_event["source"] = body.source request.app.state.broadcaster.broadcast(visibility_event) - event: dict = {"type": "panel_focus", "panel": body.panel} - if body.url: - event["url"] = _prefix_path(body.url) - if body.source: - event["source"] = body.source - request.app.state.broadcaster.broadcast(event) + if body.source == "agent": + event: dict = {"type": "panel_focus", "panel": body.panel, "source": body.source} + if body.url: + event["url"] = _prefix_path(body.url) + request.app.state.broadcaster.broadcast(event) return {"status": "ok", "active_panel": body.panel} diff --git a/src/osprey/interfaces/web_terminal/static/js/dock-sync.js b/src/osprey/interfaces/web_terminal/static/js/dock-sync.js index f34f07c5a..1f7f35e4d 100644 --- a/src/osprey/interfaces/web_terminal/static/js/dock-sync.js +++ b/src/osprey/interfaces/web_terminal/static/js/dock-sync.js @@ -366,15 +366,34 @@ export function withEchoSuppressed(fn) { /** * Handle a dockview active-panel change. Skips while an echo window is open * (a server-applied focus) and for the native terminal/workspace panels; a - * genuine human dock-tab focus of a service panel POSTs setPanelFocus, whose - * SSE echo then drives the rail + iframe through panel-manager (agent ≡ human). + * genuine human dock-tab focus of a service panel applies locally through the + * registered focus handler (rail accent, active-tab state — panel-manager's + * activateTab) and POSTs setPanelFocus as a REPORT: the server mirrors the + * active panel for the agent's gaze and broadcasts nothing for human gestures, + * so the local apply cannot ride an SSE echo. */ function onActivePanelChange() { if (suppressDepth > 0) return; const api = getDockApi(); if (!api) return; const id = serviceIdOf(api.activePanel?.id); - if (id) setPanelFocus(id); + if (!id) return; + tileFocusHandler?.(id); + setPanelFocus(id); +} + +/** + * Handler a human dock-tab focus is routed to, registered by panel-manager + * (which owns the rail accent and active-tab state the focus must update). + * Called with the focused panel's service id; must NOT POST — this module + * owns the report. + * @type {((serviceId: string) => void) | null} + */ +let tileFocusHandler = null; + +/** @param {((serviceId: string) => void) | null} fn */ +export function setTileFocusHandler(fn) { + tileFocusHandler = fn; } /** diff --git a/src/osprey/interfaces/web_terminal/static/js/dock-tab.js b/src/osprey/interfaces/web_terminal/static/js/dock-tab.js index a0fcd0e64..b8726560d 100644 --- a/src/osprey/interfaces/web_terminal/static/js/dock-tab.js +++ b/src/osprey/interfaces/web_terminal/static/js/dock-tab.js @@ -24,6 +24,7 @@ import { TERMINAL_RAIL_ID } from './panel-catalog.js'; import { registerContribHost, unregisterContribHost } from './tile-header-contrib.js'; import { PLACEHOLDER_PREFIX } from './dock-reconcile.js'; +import { withEchoSuppressed } from './dock-sync.js'; /** defaultTabComponent name registered on the dockview instance. */ export const OSPREY_TAB_COMPONENT = 'osprey-tile-tab'; @@ -168,7 +169,11 @@ class TileTab { close.addEventListener('click', (e) => { if (e.defaultPrevented) return; e.preventDefault(); - this._api?.close?.(); + // The removal makes dockview auto-activate a surviving tile; that is a + // side effect of the close, not a human focus gesture, so it must not + // reach the focus reporter — the same suppression retireTile applies to + // its own removal. + withEchoSuppressed(() => this._api?.close?.()); }); actions.appendChild(close); root.appendChild(actions); diff --git a/src/osprey/interfaces/web_terminal/static/js/panel-manager.js b/src/osprey/interfaces/web_terminal/static/js/panel-manager.js index 98cd18a1c..6ec308304 100644 --- a/src/osprey/interfaces/web_terminal/static/js/panel-manager.js +++ b/src/osprey/interfaces/web_terminal/static/js/panel-manager.js @@ -35,7 +35,7 @@ import { createPanelIframe } from './panel-iframe-factory.js'; import { PANELS, TERMINAL_RAIL_ID, TERMINAL_RAIL_LABEL, DEFAULT_PANEL_FALLBACK, } from './panel-catalog.js'; -import { initDockSync, withEchoSuppressed, setTileCloseHandler } from './dock-sync.js'; +import { initDockSync, withEchoSuppressed, setTileCloseHandler, setTileFocusHandler } from './dock-sync.js'; import { initRailDrag, railDragStart, railDragEnd } from './rail-drag.js'; import { startHealthPolling as startPolling } from './panel-health.js'; import { openTerminalPanel, closeTerminalPanel } from './dock-workspace.js'; @@ -301,6 +301,12 @@ export async function initPanelManager(panelId) { // active state here, never POST. setTileCloseHandler(vacatePanel); + // A human focusing a dock tab applies locally through activateTab (rail + // accent, active-tab state, iframe reveal). dock-sync owns the mirror POST, + // and the server does not echo human focus back, so this registration is the + // only thing that keeps the gesturing client's own rail in step. + setTileFocusHandler(activateTab); + // Hand the adapter a live reference to the visible set (it prunes restored // placeholders of server-closed panels), then finalize the registry — the // adapter may now prune any restored placeholder whose service no longer @@ -407,18 +413,19 @@ export async function initPanelManager(panelId) { const data = /** @type {PanelSSEEvent} */ (raw); if (data.type === 'panel_focus' && data.panel) { - // A switch — agent or human — honor unconditionally. It also ends the - // simple-UX chat-only suppression, even when the activation still - // refuses (unhealthy panel): the intent to surface the workspace is - // clear, so the next health settle may fill the slot. + // A broadcast switch also ends the simple-UX chat-only suppression, + // even when the activation still refuses (unhealthy panel): the + // intent to surface the workspace is clear, so the next health + // settle may fill the slot. workspaceSuppressed = false; if (data.url) navigatePanel(data.panel, data.url); // An AGENT switch is polite: focus the panel's own tile, or open one // beside the operator's — never take a tile away (applyAgentSwitch). - // Every other frame is the echo of a human gesture (rail click, dock - // tab focus) whose takeover semantics are the operator's own choice, - // so it keeps the plain activation. The glow runs after the switch so - // a just-added entry can flash. + // Human focus is never broadcast (the server mirrors it silently and + // the gesturing client applies it locally), so an unattributed frame + // can only come from an out-of-contract caller; it keeps the plain + // activation. The glow runs after the switch so a just-added entry + // can flash. if (data.source === 'agent') { applyAgentSwitch(data.panel); flashAgentGlow(data.panel); diff --git a/tests/e2e/web_terminals/test_prefix_routing.py b/tests/e2e/web_terminals/test_prefix_routing.py index e0293e0c2..fdbe0f63b 100644 --- a/tests/e2e/web_terminals/test_prefix_routing.py +++ b/tests/e2e/web_terminals/test_prefix_routing.py @@ -405,7 +405,10 @@ async def capturing_request(*, method, url, headers, content): def test_panel_focus_relative_url_gets_prefixed(self, alice_client): app, client = alice_client - resp = client.post("/api/panel-focus", json={"panel": "my-dash", "url": "/panel/my-dash"}) + resp = client.post( + "/api/panel-focus", + json={"panel": "my-dash", "url": "/panel/my-dash", "source": "agent"}, + ) assert resp.status_code == 200 event = app.state.broadcaster.broadcast.call_args[0][0] assert event["url"] == f"{_PREFIX}/panel/my-dash" @@ -416,7 +419,7 @@ def test_panel_focus_absolute_url_passes_through_unchanged(self, alice_client): app, client = alice_client resp = client.post( "/api/panel-focus", - json={"panel": "my-dash", "url": "https://grafana.lan:3000/d/abc"}, + json={"panel": "my-dash", "url": "https://grafana.lan:3000/d/abc", "source": "agent"}, ) assert resp.status_code == 200 event = app.state.broadcaster.broadcast.call_args[0][0] diff --git a/tests/interfaces/web_terminal/test_app.py b/tests/interfaces/web_terminal/test_app.py index f5cc4e78a..83d8c8370 100644 --- a/tests/interfaces/web_terminal/test_app.py +++ b/tests/interfaces/web_terminal/test_app.py @@ -202,7 +202,7 @@ def test_set_panel_focus_broadcasts_event(self, client): # Subscribe before sending q = broadcaster.subscribe() - client.post("/api/panel-focus", json={"panel": "artifacts"}) + client.post("/api/panel-focus", json={"panel": "artifacts", "source": "agent"}) # The event should be in the queue assert not q.empty() diff --git a/tests/interfaces/web_terminal/test_panels_browser.py b/tests/interfaces/web_terminal/test_panels_browser.py index f6d7fdb51..fb21787cb 100644 --- a/tests/interfaces/web_terminal/test_panels_browser.py +++ b/tests/interfaces/web_terminal/test_panels_browser.py @@ -935,8 +935,12 @@ def test_server_sse_focus_is_applied_without_posting_back(tmp_path, chromium_bro page.wait_for_timeout(800) posts = _track_panel_posts(page) - # Server-driven focus back to the already-docked data-viz. - r = requests.post(f"{base_url}/api/panel-focus", json={"panel": "data-viz"}) + # Server-driven focus back to the already-docked data-viz. The source + # tag is what makes the server broadcast at all — a source-less human + # report is mirrored without a frame. + r = requests.post( + f"{base_url}/api/panel-focus", json={"panel": "data-viz", "source": "agent"} + ) assert r.status_code == 200 # It is applied — data-viz's tile takes the active focus (artifacts keeps diff --git a/tests/interfaces/web_terminal/test_panels_collab_browser.py b/tests/interfaces/web_terminal/test_panels_collab_browser.py index 504a9ab1e..362e60d45 100644 --- a/tests/interfaces/web_terminal/test_panels_collab_browser.py +++ b/tests/interfaces/web_terminal/test_panels_collab_browser.py @@ -569,17 +569,9 @@ def test_arrange_converges_from_two_layouts_without_a_report_loop(tmp_path, chro _open_beside(page_b, "scope") _open_beside(page_b, "data-viz") - # Opening a tile reports the focus, and a focus IS broadcast — every - # client applies it with its own takeover semantics, so B's setup has - # meanwhile swapped the panel in A's single tile. A's start layout is - # therefore established last, with a plain rail click (which only moves - # focus on B, where artifacts already holds a tile of its own). Wait for - # B's LAST focus to have landed on A first, or the click races it and - # the takeover happens in the wrong order. - expect( - page_a.locator('button.panel-rail-button[data-panel-id="data-viz"].active') - ).to_have_count(1, timeout=10_000) - page_a.locator('button.panel-rail-button[data-panel-id="artifacts"]').click() + # B's open-beside gestures report their focus but broadcast nothing — + # human focus stays local — so A's boot layout (artifacts alone) is + # untouched by B's setup and needs no re-establishing. _wait_for_client_tiles(page_a, ["artifacts"]) assert _client_open_tiles(page_b) == ["artifacts", "scope", "data-viz"], _client_open_tiles( page_b @@ -1067,3 +1059,84 @@ def test_occupancy_read_back_distinguishes_unknown_from_empty(tmp_path, chromium assert isinstance(state["open_tiles_age_s"], float), state dockless.close() + + +# =========================================================================== +# (e) Human gestures stay local: no focus command leaks, no cross-client echo +# =========================================================================== + + +def test_tile_close_with_two_tiles_commands_nothing(tmp_path, chromium_browser): + """A tile "×" with a SECOND service tile open still commands nothing. + + The single-tile variant is pinned in the sibling suite; with two tiles the + close makes dockview auto-activate the surviving service tile, and that + activation runs outside any human focus gesture — it must stay inside the + echo guard exactly like retireTile's removal does, or the close leaks a + ``setPanelFocus`` command the design says it must not send. The assertion + window is generous because the leaked POST arrives asynchronously, well + after the tab is gone. + """ + workspace = tmp_path / "_agent_data" + workspace.mkdir() + + with _live_server(workspace, enabled_panels={"artifacts"}, custom_panels=[_DATA_VIZ]) as ( + base_url, + _app, + ): + page = _open_page(chromium_browser, base_url) + _open_beside(page, "data-viz") + _wait_for_client_tiles(page, ["artifacts", "data-viz"]) + page.wait_for_timeout(800) # drain boot/open-beside traffic + + posts = _track_panel_posts(page) + _close_tile(page, "data-viz") + page.wait_for_timeout(1500) # the leak arrives asynchronously + + commands = [e for e in _endpoints(posts) if e != "panel-layout"] + assert commands == [], f"a human tile close must not command, got {commands}" + + page.close() + + +def test_human_focus_stays_local_to_the_gesturing_client(tmp_path, chromium_browser): + """One operator's focus gestures never move another client's workspace. + + Client B opens a second tile (its activation tail reports the focus via + ``setPanelFocus`` — a human gesture). Client A must keep its own active + panel and its own tile set: human focus is a REPORT the server mirrors for + the agent's benefit, never a command broadcast back to other clients + (the contract stated in panel-commands.js and the collaborative-panels + design). The server-side mirror is asserted off the same gesture, so this + cannot pass by the report silently not landing. + """ + workspace = tmp_path / "_agent_data" + workspace.mkdir() + + with _live_server(workspace, enabled_panels={"artifacts"}, custom_panels=[_DATA_VIZ]) as ( + base_url, + _app, + ): + page_a = _open_page(chromium_browser, base_url) + _wait_for_client_tiles(page_a, ["artifacts"]) + page_b = _open_page(chromium_browser, base_url) + _wait_for_client_tiles(page_b, ["artifacts"]) + assert _active_rail_id(page_a) == "artifacts" + + # B's human gesture: open data-viz beside (activation reports focus). + _open_beside(page_b, "data-viz") + _wait_for_client_tiles(page_b, ["artifacts", "data-viz"]) + + # The server mirrors the gesture for the agent's gaze... + _wait_for_active(base_url, "data-viz") + page_a.wait_for_timeout(1000) # ...but no echo may reach client A: + + assert _active_rail_id(page_a) == "artifacts", ( + "client B's human focus gesture moved client A's active panel" + ) + assert _client_open_tiles(page_a) == ["artifacts"], ( + "client B's human focus gesture changed client A's tiles" + ) + + page_a.close() + page_b.close() diff --git a/tests/interfaces/web_terminal/test_panels_prefix.py b/tests/interfaces/web_terminal/test_panels_prefix.py index cf8441465..a037f1f85 100644 --- a/tests/interfaces/web_terminal/test_panels_prefix.py +++ b/tests/interfaces/web_terminal/test_panels_prefix.py @@ -202,7 +202,10 @@ def test_root_absolute_url_prefixed_under_user(self, monkeypatch): monkeypatch.setenv("OSPREY_TERMINAL_USER", "alice") client = self._client() - resp = client.post("/api/panel-focus", json={"panel": "ariel", "url": "/panel/ariel"}) + resp = client.post( + "/api/panel-focus", + json={"panel": "ariel", "url": "/panel/ariel", "source": "agent"}, + ) assert resp.status_code == 200 event = client.app.state.broadcaster.broadcast.call_args[0][0] @@ -214,7 +217,7 @@ def test_absolute_url_passed_through_unchanged(self, monkeypatch): resp = client.post( "/api/panel-focus", - json={"panel": "ariel", "url": "https://grafana.lan:3000/d/abc"}, + json={"panel": "ariel", "url": "https://grafana.lan:3000/d/abc", "source": "agent"}, ) assert resp.status_code == 200 @@ -225,7 +228,10 @@ def test_protocol_relative_url_passed_through_unchanged(self, monkeypatch): monkeypatch.setenv("OSPREY_TERMINAL_USER", "alice") client = self._client() - resp = client.post("/api/panel-focus", json={"panel": "ariel", "url": "//evil.example/x"}) + resp = client.post( + "/api/panel-focus", + json={"panel": "ariel", "url": "//evil.example/x", "source": "agent"}, + ) assert resp.status_code == 200 event = client.app.state.broadcaster.broadcast.call_args[0][0] @@ -235,7 +241,10 @@ def test_root_absolute_url_empty_prefix_unchanged(self, monkeypatch): monkeypatch.delenv("OSPREY_TERMINAL_USER", raising=False) client = self._client() - resp = client.post("/api/panel-focus", json={"panel": "ariel", "url": "/panel/ariel"}) + resp = client.post( + "/api/panel-focus", + json={"panel": "ariel", "url": "/panel/ariel", "source": "agent"}, + ) assert resp.status_code == 200 event = client.app.state.broadcaster.broadcast.call_args[0][0] diff --git a/tests/interfaces/web_terminal/test_panels_routes.py b/tests/interfaces/web_terminal/test_panels_routes.py index d0f8cf1b1..2c2fd99ff 100644 --- a/tests/interfaces/web_terminal/test_panels_routes.py +++ b/tests/interfaces/web_terminal/test_panels_routes.py @@ -240,10 +240,15 @@ def test_focus_on_non_member_broadcasts_visibility_before_focus(): """Ordering is load-bearing: clients add the rail entry, then focus it.""" app = _make_focus_app(visible=["ariel"]) with TestClient(app) as client: - client.post("/api/panel-focus", json={"panel": "grafana"}) + client.post("/api/panel-focus", json={"panel": "grafana", "source": "agent"}) frames = _frames(app) assert [f["type"] for f in frames] == ["panel_visibility", "panel_focus"] - assert frames[0] == {"type": "panel_visibility", "panel": "grafana", "visible": True} + assert frames[0] == { + "type": "panel_visibility", + "panel": "grafana", + "visible": True, + "source": "agent", + } def test_focus_visibility_frame_carries_the_source_tag(): @@ -255,11 +260,11 @@ def test_focus_visibility_frame_carries_the_source_tag(): def test_focus_on_member_emits_only_a_focus_frame(): - """A human rail click is unchanged — no spurious visibility traffic.""" + """An agent switch to a member panel: focus frame only, no visibility traffic.""" app = _make_focus_app(visible=["ariel", "grafana"]) with TestClient(app) as client: - client.post("/api/panel-focus", json={"panel": "grafana"}) - assert _frames(app) == [{"type": "panel_focus", "panel": "grafana"}] + client.post("/api/panel-focus", json={"panel": "grafana", "source": "agent"}) + assert _frames(app) == [{"type": "panel_focus", "panel": "grafana", "source": "agent"}] def test_focus_on_member_leaves_rail_order_untouched(): @@ -274,7 +279,9 @@ def test_focus_without_explicit_membership_treats_enabled_as_the_rail(): app = _make_focus_app(visible=None) with TestClient(app) as client: client.post("/api/panel-focus", json={"panel": "ariel"}) - assert _frames(app) == [{"type": "panel_focus", "panel": "ariel"}] + # A member focus adds no membership, and a human focus broadcasts nothing. + assert _frames(app) == [] + assert not hasattr(app.state, "visible_panels") def test_focus_without_explicit_membership_still_adds_a_custom_non_member(): @@ -282,7 +289,9 @@ def test_focus_without_explicit_membership_still_adds_a_custom_non_member(): app = _make_focus_app(visible=None) with TestClient(app) as client: client.post("/api/panel-focus", json={"panel": "grafana"}) - assert [f["type"] for f in _frames(app)] == ["panel_visibility", "panel_focus"] + # Membership is shared state, so the visibility frame broadcasts even for a + # human gesture; the focus itself stays a local matter. + assert [f["type"] for f in _frames(app)] == ["panel_visibility"] assert "grafana" in app.state.visible_panels @@ -299,7 +308,34 @@ def test_focus_with_url_keeps_the_url_on_the_focus_frame_only(): """The visibility frame is membership-only; the url rides the focus frame.""" app = _make_focus_app(visible=["ariel"]) with TestClient(app) as client: - client.post("/api/panel-focus", json={"panel": "grafana", "url": "/x"}) + client.post("/api/panel-focus", json={"panel": "grafana", "url": "/x", "source": "agent"}) visibility, focus = _frames(app) assert "url" not in visibility assert focus["url"].endswith("/x") + + +# ---- Human focus is a report, not a command ---- # +# +# panel-commands.js states the contract: a user-initiated tab switch is +# REPORTED "so the server mirrors the active panel (and does not echo a focus +# event back)". Only agent-attributed focus is a command that must reach every +# client. These tests pin the split. + + +def test_human_focus_mirrors_active_panel_without_broadcast(): + """A source-less (human) focus updates the mirror and broadcasts nothing.""" + app = _make_focus_app(visible=["ariel", "grafana"]) + with TestClient(app) as client: + resp = client.post("/api/panel-focus", json={"panel": "grafana"}) + body = client.get("/api/panel-focus").json() + assert resp.status_code == 200 + assert body["active_panel"] == "grafana" + app.state.broadcaster.broadcast.assert_not_called() + + +def test_agent_focus_broadcasts_a_focus_frame(): + """An agent switch is a command: every client applies the focus frame.""" + app = _make_focus_app(visible=["ariel", "grafana"]) + with TestClient(app) as client: + client.post("/api/panel-focus", json={"panel": "grafana", "source": "agent"}) + assert _frames(app) == [{"type": "panel_focus", "panel": "grafana", "source": "agent"}] diff --git a/tests/interfaces/web_terminal/test_panels_source_tag.py b/tests/interfaces/web_terminal/test_panels_source_tag.py index a345272dd..f16f44acb 100644 --- a/tests/interfaces/web_terminal/test_panels_source_tag.py +++ b/tests/interfaces/web_terminal/test_panels_source_tag.py @@ -62,13 +62,12 @@ def test_agent_source_broadcast(self): assert frame["type"] == "panel_focus" assert frame["source"] == "agent" - def test_no_source_key_when_omitted(self): + def test_no_broadcast_when_source_omitted(self): + """A source-less focus is a human report: mirrored, never broadcast.""" client = _make_client() resp = client.post("/api/panel-focus", json={"panel": "ariel"}) assert resp.status_code == 200 - frame = _broadcast_frame(client) - assert frame == {"type": "panel_focus", "panel": "ariel"} - assert "source" not in frame + client.app.state.broadcaster.broadcast.assert_not_called() class TestPanelVisibilitySource: