feat(gateway): server→client JSON-RPC requests replace the *.request/*.respond event pairs (#110521)
The gateway asked the user questions (approval, clarify, sudo, secret,
vault, MCP setup, the desktop read/act bridges) by emitting a
`<x>.request` EVENT carrying a hand-minted request_id, blocking the
agent thread on a module dict keyed by that id, and exposing a paired
`<x>.respond` METHOD per kind — thirteen pairs, four registries
(`_pending`, `_answers`, `_batch_clarify`, `_EXPIRING_REQUESTS`) and a
per-kind reconnect snapshot (`pending_clarify` / `pending_approval`)
that only two of the thirteen kinds ever got. JSON-RPC already has the
primitive: the server sends a request frame with an id and the client
answers with a response frame bearing the same id.
`tui_gateway/server_requests.py` owns the one mechanism:
send() block the agent thread until the response frame
(`srq-<n>` ids; ints belong to the client)
send_async() fire-and-callback variant (bot relay)
cancel*() withdraw with ONE `request.cancel {id, method, reason}`
event (timeout / interrupt / process exit /
answered elsewhere) instead of per-kind *.expire
open_requests() the still-open frames, replayed by session.resume,
session.activate and session.events.since so a
reconnecting client re-renders every kind, not two
clarify.lock stays a real client→server RPC (locks one batch
answer early); locked answers merge into the final
set even when the closing response carries only the
tail the user answered last
A client that does not implement a method answers -32601 and the agent
fails fast (the old fixed-timeout "unavailable" probes for tour/preview
still work — a wire error IS an answer). Approval: the queue entry's
settle hook withdraws the request when `/approve` from another surface,
a timeout or an interrupt resolves it first, so no window keeps a dead
card. Compute-host children own their waits; the parent mirrors their
open frames for replay and relays `clarify.lock` + response frames.
Clients: `JsonRpcRequestChannel` gains `onRequest` (unhandled → -32601,
dedup by id) and `JsonRpcGatewayClient` re-delivers `open_requests`
from the replay result. Desktop gets `gateway-event/server-requests.ts`
(one handler per method, replacing the request branches of
`input-requests.ts` / `desktop-bridge.ts`) and a `store/server-requests`
registry so every answer site calls `respondToServerRequest(id, result)`
synchronously; the TUI gets `createServerRequestHandler.ts` +
`serverRequestStore.ts`. `gateway-events.json` now pins both halves
(events + server request methods); the two contract tests check both.
Live (real stdio gateway, real `clarify_callback` on the agent thread):
before, `clarify.request` event + `clarify.respond` RPC, batch final
answers lost ('' returned); after, `{"id":"srq-…","method":"clarify"}`
frame, `session.events.since.open_requests` replays it, response frame
`{"answer":"yes"}` reaches the agent, batch lock + final response
merge to `{"q0":"1","q1":"free text"}`.
This commit is contained in:
@@ -68,14 +68,19 @@ class ServerRequest:
|
||||
_lock = threading.Lock()
|
||||
_open: dict[str, ServerRequest] = {}
|
||||
|
||||
# Frame sinks, bound by ``bind_sinks`` from server.py at import time (like the method_ctx split
|
||||
# modules): importing server back from here would pick a different module object under the test
|
||||
# fixtures that patch ``sys.modules`` around the server import.
|
||||
_write: Callable[[dict], Any] = lambda frame: None # noqa: E731
|
||||
_emit: Callable[[str, str, dict], Any] = lambda event, sid, payload: None # noqa: E731
|
||||
|
||||
def _write(frame: dict) -> None:
|
||||
from tui_gateway.server import write_json
|
||||
write_json(frame)
|
||||
|
||||
def bind_sinks(write_json: Callable[[dict], Any], emit: Callable[[str, str, dict], Any]) -> None:
|
||||
global _write, _emit
|
||||
_write, _emit = write_json, emit
|
||||
|
||||
|
||||
def _emit_cancel(req: ServerRequest, reason: str) -> None:
|
||||
from tui_gateway.server import _emit
|
||||
_emit("request.cancel", req.sid, {"id": req.id, "method": req.method, "reason": reason})
|
||||
|
||||
|
||||
@@ -144,6 +149,14 @@ def resolve_response(frame: dict) -> bool:
|
||||
else:
|
||||
result = frame.get("result")
|
||||
req.result = result if isinstance(result, dict) else {}
|
||||
if req.qids and "answers" in req.result:
|
||||
# Batch clarify: answers locked early via clarify.lock belong to the final set even when
|
||||
# the closing response only carries the tail the user answered last.
|
||||
answers = req.result.get("answers")
|
||||
merged = dict(req.locked)
|
||||
if isinstance(answers, dict):
|
||||
merged.update(answers)
|
||||
req.result = {**req.result, "answers": merged}
|
||||
req.answered = True
|
||||
if req.on_result is not None:
|
||||
req.on_result(req.result)
|
||||
|
||||
Reference in New Issue
Block a user