From 4d4e9d623c07734d2d3f3722390aced221a6c82f Mon Sep 17 00:00:00 2001 From: finn763 <165816600+finn763@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:45:36 +0800 Subject: [PATCH] fix(desktop): scope Bot Screen display.* to the bot backend profile display.* handlers are @_profile_scoped: without an explicit profile param the gateway answers for the launch home, so every Bot Mode screen pane attached to :20. Dedicated secondaries forward params unchanged, so displayRequest now sends the route backend profile on every call. --- .../hermes-bots/screen-connection.test.ts | 33 ++++++++++++++++++- .../plugins/hermes-bots/screen-connection.ts | 11 ++++++- .../screen-portal-status-request.test.tsx | 4 ++- 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/plugins/hermes-bots/screen-connection.test.ts b/apps/desktop/src/plugins/hermes-bots/screen-connection.test.ts index 4bb922779b..057205b20a 100644 --- a/apps/desktop/src/plugins/hermes-bots/screen-connection.test.ts +++ b/apps/desktop/src/plugins/hermes-bots/screen-connection.test.ts @@ -4,7 +4,7 @@ * screen pane. The event must also have arrived on the bot's own connection. */ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as routing from './routing' import type { RosterRow } from './types' @@ -79,10 +79,41 @@ describe('isEventForBotScreen', () => { }) describe('displayRequest', () => { + beforeEach(() => { + vi.clearAllMocks() + routeMock.mockReturnValue(null) + }) + it('rejects instead of throwing synchronously for a row whose connection was removed', async () => { routeMock.mockReturnValue(null) await expect(displayRequest(orphan, 'display.status')).rejects.toThrow(/no connection owner/) expect(host.requestProfile).not.toHaveBeenCalled() }) + + it('scopes display.status to the bot backend profile instead of the launch display', async () => { + routeMock.mockReturnValue({ connectionId: 'conn-b', profile: 'home-ops' }) + vi.mocked(host.requestProfile).mockResolvedValue({}) + + await displayRequest({ name: 'home-ops' } as RosterRow, 'display.status') + + expect(host.requestProfile).toHaveBeenCalledWith( + expect.objectContaining({ connectionId: 'conn-b', profile: 'home-ops' }), + 'display.status', + { profile: 'home-ops' } + ) + }) + + it('keeps caller params alongside the injected profile', async () => { + routeMock.mockReturnValue({ connectionId: 'conn-b', profile: 'home-ops' }) + vi.mocked(host.requestProfile).mockResolvedValue({}) + + await displayRequest({ name: 'home-ops' } as RosterRow, 'display.observe', { viewer_id: 'v1' }) + + expect(host.requestProfile).toHaveBeenCalledWith( + expect.objectContaining({ connectionId: 'conn-b', profile: 'home-ops' }), + 'display.observe', + { viewer_id: 'v1', profile: 'home-ops' } + ) + }) }) diff --git a/apps/desktop/src/plugins/hermes-bots/screen-connection.ts b/apps/desktop/src/plugins/hermes-bots/screen-connection.ts index 13c8ce7a7c..ea647fc13c 100644 --- a/apps/desktop/src/plugins/hermes-bots/screen-connection.ts +++ b/apps/desktop/src/plugins/hermes-bots/screen-connection.ts @@ -121,7 +121,16 @@ export function displayRequest(bot: RosterRow, method: string, params: Record return Promise.reject(new Error(`Bot ${bot.name} has no connection owner`)) } - return host.requestProfile(route, method, params) + // display.* handlers are @_profile_scoped: without an explicit `profile` the + // gateway answers for the LAUNCH home, so every bot pane streams :20 (#120966). + // Dedicated secondaries forward params unchanged (only shared-primary routes + // get `profile` injected downstream), so scope here, at the one choke point + // every Bot Screen RPC flows through. The route owns the identity: a caller + // param never overrides it. (Inline `targetProfile || profile` like the other + // call sites, so partial `./routing` mocks keep working.) + const profile = typeof route === 'string' ? route : route.targetProfile || route.profile + + return host.requestProfile(route, method, { ...params, profile }) } /** diff --git a/apps/desktop/src/plugins/hermes-bots/screen-portal-status-request.test.tsx b/apps/desktop/src/plugins/hermes-bots/screen-portal-status-request.test.tsx index b519f1ef86..e7f564c0d1 100644 --- a/apps/desktop/src/plugins/hermes-bots/screen-portal-status-request.test.tsx +++ b/apps/desktop/src/plugins/hermes-bots/screen-portal-status-request.test.tsx @@ -72,7 +72,9 @@ it('a portal status reply superseded by a newer status request does not move run ) const view = renderHook(() => useScreenPortalState(bot)) - expect(host.requestProfile).toHaveBeenCalledWith('ops', 'display.status', {}) + // `displayRequest` injects the route's profile at the choke point, so the + // RPC carries it even when the caller passed no params (#120966). + expect(host.requestProfile).toHaveBeenCalledWith('ops', 'display.status', { profile: 'ops' }) // The pane opens while the portal's fetch is still in flight and asks again (newer request). const paneRequest = beginScreenStatusRequest(bot)