fix(desktop): refuse window-owned pane requests at once when no window shows the session

preview.read / preview.act / terminal.read / window.read / tour are answered
only by the Desktop window that shows the requesting session (#114981). When
no attached window showed it, every window stayed silent and the agent waited
out the full 30-45s bridge deadline, then got the generic "No preview tab is
open, or the read timed out."

A window not hosting the session now declines with JSON-RPC error 4404
instead of staying silent. The gateway counts a decline as that client's vote,
not as the answer: the request stays open for the owner window and settles
only once every attached answering client declined, with a distinct refusal
("No Hermes Desktop window is showing this chat ... Ask the user to open this
chat in the Desktop app, then retry."). A bystander window therefore still
cannot beat the owner (#113348).

The backend advertises the counting via client.capabilities
(declines_not_shown); the shared channel only sends a decline to a backend
that did, so a new app against an older backend keeps today's silence.

Fixes #119333
This commit is contained in:
Austin Pickett
2026-09-28 19:32:08 -04:00
parent 0f50687327
commit e33f1a0faf
13 changed files with 268 additions and 22 deletions

View File

@@ -20,17 +20,18 @@ const deps = {
upsertToolCall: () => undefined
} as ServerRequestContext['deps']
function deliver(method: string, params: Record<string, unknown>, activeSessionId: null | string) {
function deliver(method: string, params: Record<string, unknown>, activeSessionId: null | string, replayed?: boolean) {
const respond = vi.fn()
const fail = vi.fn()
const decline = vi.fn()
const handled = handleServerRequest(
{ fail, id: 'srq-1', method, params, profile: 'default', respond },
{ decline, fail, id: 'srq-1', method, params, profile: 'default', replayed, respond },
deps,
activeSessionId
)
return { fail, handled, respond }
return { decline, fail, handled, respond }
}
describe('connection request routing', () => {
@@ -91,31 +92,44 @@ describe('preview action request routing', () => {
expect(previewSessionRoute({ replayed: true, sessionId: '', activeSessionId: null })).toBe('run')
})
it('leaves a scoped action request unanswered in a window showing another session', () => {
const { handled, respond, fail } = deliver(
it('declines a scoped action request in a window showing another session instead of answering it', () => {
// A decline leaves the request open for the owner window; the backend
// settles only when every attached window declined (#119333, #113348).
const { decline, handled, respond, fail } = deliver(
'preview.act',
{ action: 'elements', session_id: 'session-a' },
'session-b'
)
expect(handled).toBe(true)
expect(decline).toHaveBeenCalledTimes(1)
expect(respond).not.toHaveBeenCalled()
expect(fail).not.toHaveBeenCalled()
})
it('leaves scoped pane reads unanswered in a window showing another session', async () => {
it('declines scoped pane reads in a window showing another session', async () => {
const reads = ['preview.read', 'terminal.read', 'window.read'].map(method =>
deliver(method, { session_id: 'session-a' }, 'session-b')
)
await Promise.resolve()
for (const { handled, respond } of reads) {
for (const { decline, handled, respond } of reads) {
expect(handled).toBe(true)
expect(decline).toHaveBeenCalledTimes(1)
expect(respond).not.toHaveBeenCalled()
}
})
it('declines a replayed request only after the retry still finds no host', async () => {
const replay = deliver('preview.read', { session_id: 'session-a' }, null, true)
expect(replay.decline).not.toHaveBeenCalled()
await new Promise(resolve => setTimeout(resolve, 0))
expect(replay.decline).toHaveBeenCalledTimes(1)
expect(replay.respond).not.toHaveBeenCalled()
})
it("answers pane reads for a session hosted in one of this window's tiles", async () => {
// The tile session is not the active one, but this window hosts it: its
// panes are here, so an 'ignore' would stall the tool until its deadline.
@@ -128,9 +142,10 @@ describe('preview action request routing', () => {
await new Promise(resolve => setTimeout(resolve, 0))
for (const { handled, respond } of reads) {
for (const { decline, handled, respond } of reads) {
expect(handled).toBe(true)
expect(respond).toHaveBeenCalledTimes(1)
expect(decline).not.toHaveBeenCalled()
}
} finally {
$sessionTiles.set([])
@@ -149,11 +164,12 @@ describe('tour request routing', () => {
$toursEnabled.set(true)
})
it('leaves a scoped request unanswered in another session even when tours are disabled', () => {
it('declines a scoped request in another session even when tours are disabled', () => {
$toursEnabled.set(false)
const { handled, respond } = deliver('tour', { action: 'discover', session_id: 'session-a' }, 'session-b')
const { decline, handled, respond } = deliver('tour', { action: 'discover', session_id: 'session-a' }, 'session-b')
expect(handled).toBe(true)
expect(decline).toHaveBeenCalledTimes(1)
expect(respond).not.toHaveBeenCalled()
})

View File

@@ -78,6 +78,15 @@ type PreviewSessionRoute = 'ignore' | 'retry' | 'run'
*/
const WINDOW_OWNED_REQUESTS = new Set(['preview.act', 'preview.read', 'terminal.read', 'window.read', 'tour'])
/**
* A window not hosting the session declines instead of staying silent. The
* backend keeps the request open for the owner and only settles once every
* attached window declined, so when no window shows the chat the agent is told
* now rather than after its whole deadline (#119333). `decline` is a no-op
* against a backend that would take the first error as the answer.
*/
const declineNotShown = (request: ScopedServerRequest) => request.decline?.('This window is not showing the session.')
/**
* Whether a request's `session_id` names the same conversation as the pane's
* active session. The two sides are not always the same identity class: the
@@ -599,13 +608,15 @@ export function handleServerRequest(
const route = previewSessionRoute({ activeSessionId, replayed: request.replayed, sessionId, storedIdForRuntimeId })
if (route === 'ignore') {
declineNotShown(request)
return true
}
if (route === 'retry') {
// Re-read the ref instead of capturing activeSessionId: session resume
// publishes its binding synchronously between this replay and the next
// turn. A second miss deliberately stays silent for another window.
// turn. A second miss leaves the request to another window.
setTimeout(() => {
if (
previewSessionRoute({
@@ -616,6 +627,8 @@ export function handleServerRequest(
}) === 'run'
) {
handler({ deps, request, sessionId, isActiveSession: true })
} else {
declineNotShown(request)
}
}, 0)

View File

@@ -1821,6 +1821,7 @@ export interface ClientCapabilitiesParams {
}
export interface ClientCapabilitiesResult {
server_requests: string[]
declines_not_shown?: boolean
}
/** ``word`` is the token under the cursor (``@`` prefix = context reference); ``cwd`` / ``session_id`` pick the directory the listing resolves against. */
export interface CompletePathParams {

View File

@@ -7849,6 +7849,11 @@
},
"title": "Server Requests",
"type": "array"
},
"declines_not_shown": {
"default": false,
"title": "Declines Not Shown",
"type": "boolean"
}
},
"required": [

View File

@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { JsonRpcGatewayError, JsonRpcRequestChannel, type JsonRpcTransport } from './json-rpc-channel.js'
import { JSON_RPC_SESSION_NOT_SHOWN, JsonRpcGatewayError, JsonRpcRequestChannel, type JsonRpcTransport } from './json-rpc-channel.js'
const spyTransport = () => {
const sent: string[] = []
@@ -193,6 +193,38 @@ describe('JsonRpcRequestChannel', () => {
expect(sent).toHaveLength(1)
})
it('sends a not-shown decline only to a backend that advertised counting it', async () => {
// An older backend settles a request on the first error frame, so a bystander window's decline
// would beat the owner's answer (#113348): there `decline` must stay silent.
const channel = new JsonRpcRequestChannel({ requestIdPrefix: 'c' })
const { sent, transport, last } = spyTransport()
const ready = () =>
channel.handleFrame(JSON.stringify({ jsonrpc: '2.0', method: 'event', params: { type: 'gateway.ready', payload: {} } }))
const ask = (id: string) =>
channel.handleFrame(JSON.stringify({ id, jsonrpc: '2.0', method: 'preview.read', params: { session_id: 's1' } }))
channel.attach(transport)
channel.onRequest(req => void req.decline?.('not here'))
ready()
channel.handleFrame(JSON.stringify({ id: last().id, jsonrpc: '2.0', result: { server_requests: ['preview.read'] } }))
await Promise.resolve()
ask('srq-old')
expect(sent).toHaveLength(1)
ready()
channel.handleFrame(JSON.stringify({ id: last().id, jsonrpc: '2.0', result: { declines_not_shown: true, server_requests: [] } }))
await Promise.resolve()
ask('srq-new')
expect(JSON.parse(sent.at(-1)!)).toEqual({
error: { code: JSON_RPC_SESSION_NOT_SHOWN, message: 'not here' },
id: 'srq-new',
jsonrpc: '2.0'
})
})
it('routes a server request to the first accepting handler and answers -32601 when nobody accepts', () => {
const unhandled: string[] = []
const channel = new JsonRpcRequestChannel({ onUnhandledRequest: req => void unhandled.push(req.method) })

View File

@@ -1,3 +1,4 @@
import type { ClientCapabilitiesResult } from './gateway-contract.generated.js'
import type { GatewayEvent } from './gateway-events.js'
export type GatewayRequestId = number | string
@@ -39,6 +40,13 @@ export interface ServerRequest<M extends string = string, P extends ServerReques
respond: (result: Record<string, unknown>) => void
/** Answer with a JSON-RPC error (the backend treats it as unanswered). */
fail: (code: number, message: string) => void
/**
* "No window here shows this session" for a window-owned bridge. Sent only
* to a backend that counts it as one client declining rather than as the
* answer (`client.capabilities` → `declines_not_shown`); an older backend
* settles on the first error, so there this stays silent for the owner.
*/
decline?: (message: string) => void
/**
* Renderer-side tag set by the owner when a request arrives through a
* replay (`open_requests`) rather than live; handlers that already show the
@@ -72,6 +80,9 @@ export const JSON_RPC_METHOD_NOT_FOUND = -32601
/** JSON-RPC "internal error" — used when a server→client request handler throws. */
export const JSON_RPC_INTERNAL_ERROR = -32603
/** A window-owned request's session is not shown by any window of this client (tui_gateway/server_requests.py::NOT_SHOWN_CODE). */
export const JSON_RPC_SESSION_NOT_SHOWN = 4404
/** Map a raw `error` member of a response frame to the typed error every surface inspects. */
export function jsonRpcErrorFromFrame(raw: unknown, fallbackMessage = 'Hermes RPC failed'): JsonRpcGatewayError {
const err = (raw && typeof raw === 'object' ? raw : {}) as JsonRpcErrorPayload
@@ -182,6 +193,8 @@ export class JsonRpcRequestChannel {
private heartbeatSequence = 0
private readonly outstandingPings = new Set<string>()
private lastLivenessAt = 0
/** The bound generation's backend counts `decline` as one client abstaining (see `ServerRequest.decline`). */
private backendCountsDeclines = false
private readonly requestHandlers: ServerRequestHandler[] = []
private readonly options: Required<
Omit<JsonRpcRequestChannelOptions, 'onEvent' | 'onHeartbeatFailure' | 'onRequestHandlerError' | 'onUnhandledRequest'>
@@ -217,6 +230,7 @@ export class JsonRpcRequestChannel {
this.stopHeartbeat()
this.transport = transport
this.lastLivenessAt = Date.now()
this.backendCountsDeclines = false
}
/** Drop the transport and fail every in-flight call with `error`. */
@@ -358,7 +372,12 @@ export class JsonRpcRequestChannel {
params,
replayed,
respond: result => send({ result }),
fail: (code, message) => send({ error: { code, message } })
fail: (code, message) => send({ error: { code, message } }),
decline: message => {
if (this.backendCountsDeclines) {
send({ error: { code: JSON_RPC_SESSION_NOT_SHOWN, message } })
}
}
}
for (const handler of this.requestHandlers) {
@@ -487,7 +506,15 @@ export class JsonRpcRequestChannel {
* that is ignored.
*/
private advertiseCapabilities(): void {
this.request('client.capabilities', { server_requests: true }).catch(() => undefined)
const transport = this.transport
this.request<Partial<ClientCapabilitiesResult> | null>('client.capabilities', { server_requests: true })
.then(result => {
if (this.transport === transport) {
this.backendCountsDeclines = result?.declines_not_shown === true
}
})
.catch(() => undefined)
}
/**

View File

@@ -403,6 +403,88 @@ def test_server_request_error_response_fails_fast(server):
assert box["result"] is None
def _two_window_session(server, sid):
"""A session two advertised Desktop windows are attached to (a FanoutTransport of WS peers)."""
import asyncio
from tui_gateway import server_requests
from tui_gateway.transport import FanoutTransport
# The fanout awaits a WS peer's write_async on its loop; a peer without one is pruned on first write.
loop = asyncio.new_event_loop()
threading.Thread(target=loop.run_forever, daemon=True).start()
windows = (_silent_ws(), _silent_ws())
for window in windows:
async def _write_async(obj, window=window):
return window.write(obj)
window._loop, window.write_async = loop, _write_async
server_requests.advertise(window, True)
_ws_session(server, sid, FanoutTransport(*windows))
return windows
def _not_shown(req):
from tui_gateway import server_requests
return {"jsonrpc": "2.0", "id": req.id, "error": {"code": server_requests.NOT_SHOWN_CODE, "message": "not here"}}
def test_window_owned_request_refuses_at_once_when_no_window_shows_the_session(server):
"""#119333: every attached window says it does not show the session → preview.read resolves with the
distinct not-shown refusal now, instead of the agent waiting out the 45s bridge deadline."""
from tui_gateway import server_requests
windows = _two_window_session(server, "s-none")
box = {}
thread = threading.Thread(target=lambda: box.setdefault("r", server._ask("preview.read", "s-none", {}, timeout=30)),
daemon=True)
thread.start()
req = _wait_open(server_requests)
t0 = time.monotonic()
assert server.dispatch(_not_shown(req), windows[0]) is None
# A replayed decline from the same window is still one window's vote.
server.dispatch(_not_shown(req), windows[0])
time.sleep(0.05)
assert thread.is_alive()
server.dispatch(_not_shown(req), windows[1])
thread.join(timeout=2)
assert not thread.is_alive() and time.monotonic() - t0 < 2
assert json.loads(box["r"]) == {"success": False, "error": server_requests.NOT_SHOWN_MESSAGE}
assert server_requests.open_requests("s-none") == []
def test_window_owned_request_decline_does_not_beat_the_owner_window(server):
"""#113348 stays fixed: one bystander window declining leaves the request open for the window that
shows the session, and its answer is the result."""
from tui_gateway import server_requests
bystander, owner = _two_window_session(server, "s-own")
box = {}
thread = threading.Thread(target=lambda: box.setdefault("r", server._ask("preview.read", "s-own", {}, timeout=5)),
daemon=True)
thread.start()
req = _wait_open(server_requests)
server.dispatch(_not_shown(req), bystander)
# An unattributed decline (proxied / relayed frame) is not a window's vote either.
assert server_requests.resolve_response(_not_shown(req)) is True
time.sleep(0.05)
assert thread.is_alive() and [r["id"] for r in server_requests.open_requests("s-own")] == [req.id]
server.dispatch({"jsonrpc": "2.0", "id": req.id, "result": {"value": '{"text": "page"}'}}, owner)
thread.join(timeout=2)
assert box["r"] == '{"text": "page"}'
def test_client_capabilities_advertises_counting_not_shown_declines(server):
from tui_gateway.transport import bind_transport, reset_transport
peer = _silent_ws()
token = bind_transport(peer)
try:
response = server.handle_request({"id": 1, "method": "client.capabilities", "params": {"server_requests": True}})
finally:
reset_transport(token)
assert response["result"]["declines_not_shown"] is True
@pytest.mark.parametrize("method", ["secret", "sudo", "terminal.read", "tour"])
def test_server_request_timeout_emits_one_request_cancel(capture, method):
from tui_gateway import server_requests

View File

@@ -36,6 +36,11 @@ class ClientCapabilitiesParams(Params):
class ClientCapabilitiesResult(Result):
#: Server→client request methods this backend may send.
server_requests: list[str]
#: This backend counts a ``4404`` "no window here shows this session" error from a window-owned
#: bridge (preview/terminal/window read, preview act, tour) as that client declining, not as the
#: answer: the request settles only once every attached client declined (server_requests.py).
#: An older backend settles on the first error, so a client sends the decline only when this is true.
declines_not_shown: bool = False
method("client.capabilities", params=ClientCapabilitiesParams, result=ClientCapabilitiesResult,

View File

@@ -447,7 +447,7 @@ def _(rid, params: dict) -> dict:
from tui_gateway import server_requests
from tui_gateway.contracts import registry as contracts
server_requests.advertise(_caller_transport(), bool(params.get("server_requests")))
return _ok(rid, {"server_requests": sorted(contracts.SERVER_REQUESTS)})
return _ok(rid, {"server_requests": sorted(contracts.SERVER_REQUESTS), "declines_not_shown": True})
@method("ping")

View File

@@ -53,7 +53,7 @@ def dispatch(req: dict, transport: Optional[Transport] = None) -> dict | None:
from tui_gateway import server_requests
if server_requests.is_response_frame(req):
# The renderer answering one of OUR requests (clarify, approval, …): no response frame goes back.
if not server_requests.resolve_response(req) and not _relay_compute_host_response(req):
if not server_requests.resolve_response(req, t) and not _relay_compute_host_response(req):
logger.debug("dropping response for unknown server request id=%r", req.get("id"))
return None
normalized = _normalize_request(req)

View File

@@ -684,7 +684,8 @@ def _emit(event: str, sid: str, payload: dict | None = None) -> bool:
from tui_gateway import server_requests as _server_requests # noqa: E402
_server_requests.bind_sinks(lambda frame: write_json(frame), lambda event, sid, payload: _emit(event, sid, payload),
lambda sid: _session_client_answers_requests(sid))
lambda sid: _session_client_answers_requests(sid),
lambda sid: _session_answering_clients(sid))
# Live WS peer transports (maintained by tui_gateway.ws): the only route for session-less background

View File

@@ -21,6 +21,12 @@ Batch clarify keeps per-question locks (``clarify.lock`` → :func:`lock_answer`
editable until every question is locked, locked answers survive a timeout, and the last lock
resolves the request with the full answer set.
Window-owned bridges (``preview.read`` / ``preview.act`` / ``terminal.read`` / ``window.read`` /
``tour``) are answered only by the window showing the session; every other attached window declines
with :data:`NOT_SHOWN_CODE`. A decline does not settle the request — the owner may still answer — until
every answering client attached to the session has declined; then the request resolves with
:data:`NOT_SHOWN_MESSAGE` at once instead of the agent waiting out the deadline (#119333).
Capability: a client says once per connection that it answers server→client requests
(``client.capabilities {server_requests: true}`` → :func:`advertise`). A WebSocket client that never
did is a build older than this half of the protocol — it drops the frame silently and the agent
@@ -30,6 +36,7 @@ return the same ``None`` an error response produces without writing the frame (#
from __future__ import annotations
import json
import logging
import threading
import time
@@ -41,7 +48,7 @@ logger = logging.getLogger(__name__)
class ServerRequest:
__slots__ = ("id", "sid", "method", "params", "event", "result", "answered", "created_at",
"qids", "locked", "on_result")
"qids", "locked", "on_result", "declined")
def __init__(self, sid: str, method: str, params: dict, *, qids: list[str] | None = None,
on_result: Callable[[dict | None], None] | None = None) -> None:
@@ -57,6 +64,8 @@ class ServerRequest:
self.qids = list(qids) if qids else None
self.locked: dict[str, str] = {}
self.on_result = on_result
# Client transports that answered NOT_SHOWN_CODE (no window there shows this session).
self.declined: set = set()
def frame(self) -> dict:
return {"jsonrpc": "2.0", "id": self.id, "method": self.method,
@@ -82,6 +91,15 @@ _emit: Callable[[str, str, dict], Any] = lambda event, sid, payload: None # noq
# ``answerable(sid)``: False only when every client attached to the session is a build that never
# advertised handling server→client requests (session_transports.py::_session_client_answers_requests).
_answerable: Callable[[str], bool] = lambda sid: True # noqa: E731
# ``clients(sid)``: the attached client transports that answer server→client requests — the set whose
# unanimous NOT_SHOWN_CODE decline settles a window-owned request (session_transports.py).
_clients: Callable[[str], list] = lambda sid: [] # noqa: E731
# Error code a client answers when none of its windows shows the request's session, and the refusal the
# tool reports once every attached client said so. Mirrored in apps/desktop server-requests.ts.
NOT_SHOWN_CODE = 4404
NOT_SHOWN_MESSAGE = ("No Hermes Desktop window is showing this chat, so its preview, terminal and tour are out "
"of reach. Ask the user to open this chat in the Desktop app, then retry.")
# Client transports that sent ``client.capabilities {server_requests: true}`` (identity set: StdioTransport
# has __slots__ and cannot be weak-referenced; ws.py forgets a peer on disconnect).
@@ -89,9 +107,11 @@ _answering_clients: set = set()
def bind_sinks(write_json: Callable[[dict], Any], emit: Callable[[str, str, dict], Any],
answerable: Callable[[str], bool]) -> None:
global _write, _emit, _answerable
answerable: Callable[[str], bool], clients: Callable[[str], list] | None = None) -> None:
global _write, _emit, _answerable, _clients
_write, _emit, _answerable = write_json, emit, answerable
if clients is not None:
_clients = clients
def advertise(transport: Any, server_requests: bool) -> None:
@@ -198,12 +218,47 @@ def send_async(method: str, sid: str, params: dict, on_result: Callable[[dict |
return settle
def resolve_response(frame: dict) -> bool:
def _is_not_shown(frame: dict) -> bool:
error = frame.get("error")
return isinstance(error, dict) and error.get("code") == NOT_SHOWN_CODE
def _decline(rid: str, transport: Any) -> bool:
"""One client's "no window here shows this session". Settles only once every answering client
attached to the session declined: a bystander window must not beat the owner (#113348), and with
no owner at all the agent gets the refusal now rather than at the deadline (#119333). A decline
from an unknown transport (relayed, proxied) is recorded nowhere and the wait goes on."""
with _lock:
req = _open.get(rid)
if req is None:
logger.debug("server request %s: decline dropped, request no longer open", rid)
return False
clients = set(_clients(req.sid)) if transport is not None else set()
with _lock:
if _open.get(rid) is not req:
return True # settled meanwhile
if transport is not None:
req.declined.add(transport)
if not clients or not clients <= req.declined:
return True
_open.pop(rid, None)
req.result = {"value": json.dumps({"success": False, "error": NOT_SHOWN_MESSAGE})}
req.answered = True
if req.on_result is not None:
req.on_result(req.result)
req.event.set()
return True
def resolve_response(frame: dict, transport: Any = None) -> bool:
"""Route one client response frame to its open request. False when nothing is waiting for that id
(already timed out / cancelled, or owned by another process — see the compute-host bridge)."""
(already timed out / cancelled, or owned by another process — see the compute-host bridge).
*transport* is the client connection the frame arrived on (counts a NOT_SHOWN_CODE decline)."""
rid = frame.get("id")
if not isinstance(rid, str):
return False
if _is_not_shown(frame):
return _decline(rid, transport)
with _lock:
req = _open.get(rid)
if req is None:

View File

@@ -47,6 +47,15 @@ def _session_client_answers_requests(sid: str) -> bool:
return not clients or any(server_requests.answers_requests(peer) for peer in clients)
def _session_answering_clients(sid: str) -> list:
"""The live WebSocket clients attached to *sid* that advertised answering server→client requests:
the windows whose unanimous "not shown here" settles a window-owned request (server_requests.py)."""
from tui_gateway import server_requests
from tui_gateway.ws import WSTransport
return [peer for peer in _session_live_transports(_sessions.get(sid))
if isinstance(peer, WSTransport) and server_requests.answers_requests(peer)]
def _warn_foreign_login(session: dict, transport) -> None:
"""Ownership is not enforced; a second login sharing a session is only logged, and the agent keeps the
creator's user id."""