diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 23781c4942a6..07a296d83019 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -14,6 +14,7 @@ import { clearQueuedPrompts, migrateQueuedPrompts } from '@/store/composer-queue import { $pinnedSessionIds } from '@/store/layout' import { clearNotifications, notify, notifyError } from '@/store/notifications' import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile' +import { setApprovalRequest } from '@/store/prompts' import { beginSessionMutation, endSessionMutation, @@ -192,6 +193,24 @@ interface FreshSessionDraftOptions { workspaceTarget?: NewChatWorkspaceTarget } +function restorePendingApproval(response: SessionResumeResponse, sessionId: string): boolean { + const pending = response.pending_approval + + if (!pending) { + return false + } + + setApprovalRequest({ + allowPermanent: pending.allow_permanent !== false, + choices: pending.choices, + command: pending.command ?? '', + description: pending.description ?? 'dangerous command', + sessionId, + smartDenied: pending.smart_denied === true + }) + return true +} + function normalizeNewChatWorkspaceTarget(target: NewChatWorkspaceTarget): NewChatWorkspaceTarget { return typeof target === 'string' ? target.trim() || null : target } @@ -767,6 +786,7 @@ export function useSessionActions({ sessionStateByRuntimeIdRef.current.delete(cachedRuntimeId) dropSessionState(cachedRuntimeId) } else { + const pendingApproval = restorePendingApproval(activated, cachedRuntimeId) const runtimeInfo = applyRuntimeInfo(activated.info) // `omit_messages` means the response carries NO transcript, not @@ -817,6 +837,7 @@ export function useSessionActions({ messages: activatedMessages, busy: running, awaitingResponse: running, + needsInput: pendingApproval || state.needsInput, // Adopting someone else's turn: we'll stream its reply // without ever having received its prompt, so the settle // path must not take the "I saw it all" shortcut. @@ -1033,6 +1054,7 @@ export function useSessionActions({ setActiveSessionId(resumed.session_id) activeSessionIdRef.current = resumed.session_id + const pendingApproval = restorePendingApproval(resumed, resumed.session_id) const runtimeInfo = applyRuntimeInfo(resumed.info) patchSessionWorkspace(storedSessionId, runtimeInfo?.cwd) @@ -1045,6 +1067,7 @@ export function useSessionActions({ messages: messagesForView, busy: resumedRunning, awaitingResponse: resumedRunning && !recoveredInFlightTail, + needsInput: pendingApproval || state.needsInput, adoptedRunningTurn: state.adoptedRunningTurn || resumedRunning, ...(inFlightRecovery.applied ? { diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index ad0ba7d180db..886832b6dd35 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -603,6 +603,16 @@ export interface SessionResumeResponse { queued?: null | { user?: string } + // The oldest gateway approval still waiting for a response. This is returned + // on resume so a reconnect can restore a prompt whose original event was + // emitted while the client transport was detached. + pending_approval?: { + allow_permanent?: boolean + choices?: string[] + command?: string + description?: string + smart_denied?: boolean + } info?: SessionRuntimeInfo message_count: number messages: SessionMessage[] diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 2265da9074b9..7cc43fcb171d 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -174,6 +174,43 @@ def test_write_json(capture): assert json.loads(buf.getvalue()) == {"test": True} +def test_live_session_payload_replays_pending_approval(server, monkeypatch): + """A reattached client receives the approval that was emitted while detached.""" + from tools import approval + + session = { + "agent": types.SimpleNamespace(), + "cols": 80, + "created_at": 1.0, + "history": [], + "history_lock": threading.Lock(), + "running": True, + "session_key": "stored-session", + } + first = { + "choices": ["once", "deny"], + "command": "rm -rf /tmp/example", + "description": "recursive delete", + } + second = {"command": "rm -rf /tmp/later", "description": "later"} + saved_queue = approval._gateway_queues.pop("stored-session", None) + approval._gateway_queues["stored-session"] = [ + approval._ApprovalEntry(first), + approval._ApprovalEntry(second), + ] + monkeypatch.setattr(server, "_approval_request_payload", lambda data: dict(data or {})) + + try: + payload = server._live_session_payload("runtime-session", session) + finally: + approval._gateway_queues.pop("stored-session", None) + if saved_queue is not None: + approval._gateway_queues["stored-session"] = saved_queue + + assert payload["pending_approval"] == first + assert payload["pending_approval"] is not first + + def test_disable_flush_env_var_actually_wires_to_module_constant(monkeypatch): """End-to-end: setting `HERMES_TUI_GATEWAY_NO_FLUSH=1` and importing `tui_gateway.transport` fresh actually flips `_DISABLE_FLUSH` true. @@ -739,4 +776,3 @@ def test_unregister_live_transport_stops_delivery(capture): assert a.frames == [] # No live transports left → fell back to stdio. assert json.loads(buf.getvalue())["params"]["type"] == "skin.changed" - diff --git a/tools/approval.py b/tools/approval.py index 50111de8d67b..0f71cef480bb 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -2525,6 +2525,22 @@ def has_blocking_approval(session_key: str) -> bool: return bool(_gateway_queues.get(session_key)) +def get_pending_gateway_approval(session_key: str) -> dict | None: + """Return a copy of the oldest unresolved gateway approval for a session. + + Reconnectable clients use this to restore an approval prompt whose original + notification was sent while their transport was detached. The queue remains + authoritative: this is a read-only snapshot, not a claim on the approval. + """ + if not session_key: + return None + with _lock: + queue = _gateway_queues.get(session_key) + if not queue: + return None + return dict(queue[0].data) + + def submit_pending(session_key: str, approval: dict): """Store a pending approval request for a session.""" with _lock: diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 4a57a98be6db..fc0793f43673 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1823,13 +1823,8 @@ def _send_compute_host_control( ) -def _emit_approval_request(sid: str, data: dict | None) -> None: - """Emit an ``approval.request`` event to the TUI client with the command - redacted. The approval payload is built from the RAW command string, so a - credential-shaped value Tirith flagged would otherwise be echoed verbatim - to the TUI client (#48456 — third egress transport alongside the chat - platforms and the SSE/API stream fixed in #50767). Reuse the shared gateway - seam so all approval transports redact consistently.""" +def _approval_request_payload(data: dict | None) -> dict: + """Build the client-safe representation of a pending approval.""" payload = dict(data or {}) if "choices" not in payload: if payload.get("smart_denied"): @@ -1842,6 +1837,29 @@ def _emit_approval_request(sid: str, data: dict | None) -> None: from gateway.run import _redact_approval_command payload["command"] = _redact_approval_command(payload.get("command")) + return payload + + +def _pending_approval_request_payload(session_key: str) -> dict | None: + """Read the oldest unresolved approval in a session, if there is one.""" + try: + from tools.approval import get_pending_gateway_approval + + approval = get_pending_gateway_approval(session_key) + except Exception: + logger.debug("failed to read pending approval for %s", session_key, exc_info=True) + return None + return _approval_request_payload(approval) if approval else None + + +def _emit_approval_request(sid: str, data: dict | None) -> None: + """Emit an ``approval.request`` event to the TUI client with the command + redacted. The approval payload is built from the RAW command string, so a + credential-shaped value Tirith flagged would otherwise be echoed verbatim + to the TUI client (#48456 — third egress transport alongside the chat + platforms and the SSE/API stream fixed in #50767). Reuse the shared gateway + seam so all approval transports redact consistently.""" + payload = _approval_request_payload(data) _emit("approval.request", sid, payload) @@ -8139,6 +8157,8 @@ def _live_session_payload( payload["inflight"] = inflight if queued: payload["queued"] = queued + if approval := _pending_approval_request_payload(str(session.get("session_key") or "")): + payload["pending_approval"] = approval return payload