Desktop session approval status indicators and notifications
NousResearch/hermes-agent · cluster 86565 · apps/desktop
Data as of
Nothing open on GitHub. Fix merged in #86772. Check it covers your case.
The short answer
- Known?
- Yes. 7 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #86772. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These items address how blocked/approval-waiting sessions are surfaced to users: fixing incorrect status dots, adding named OS notifications and sidebar badges for sessions needing input, and updating gateway to correctly report approval-blocked sessions as waiting. One item covers a gateway JSON-RPC transport change for server-to-client requests.
Open issues0
Open PRs0
Fixes on record
-
#86772 fix(desktop): unread-dot read-state overhaul — correct clears, restart persistence, manual mark-unread (salvages #74162 #74293 #76504 #82824 #84822)
files:
apps/desktop/e2e/unread-dot-restart.spec.ts,apps/desktop/src/app/chat/sidebar/index.tsx,apps/desktop/src/app/chat/sidebar/session-actions-menu.test.tsx,apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx,apps/desktop/src/app/chat/sidebar/session-row.test.tsx,apps/desktop/src/app/chat/sidebar/session-row.tsx,apps/desktop/src/app/chat/sidebar/sessions-section.test.tsx,apps/desktop/src/app/chat/sidebar/sessions-section.tsx,apps/desktop/src/app/chat/sidebar/virtual-session-list.test.tsx,apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx,apps/desktop/src/app/contrib/controller.tsx,apps/desktop/src/app/open-session.ts,apps/desktop/src/app/session/hooks/use-session-actions/index.ts,apps/desktop/src/app/settings/sessions-settings.tsx,apps/desktop/src/hermes.ts,apps/desktop/src/i18n/ar.ts,apps/desktop/src/i18n/en.ts,apps/desktop/src/i18n/ja.ts,apps/desktop/src/i18n/types.ts,apps/desktop/src/i18n/zh-hant.ts(+23 more) -
#110677 feat(gateway): server→client JSON-RPC requests replace the *.request/*.respond event pairs (#110521)
files:
apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx,apps/desktop/src/app/contrib/wiring.tsx,apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx,apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts,apps/desktop/src/app/session/hooks/use-message-stream/clarify-hydration.test.tsx,apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/desktop-bridge.test.ts,apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/desktop-bridge.ts,apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/input-requests.ts,apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/server-requests.test.ts,apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/server-requests.ts,apps/desktop/src/app/session/hooks/use-message-stream/index.ts,apps/desktop/src/app/session/hooks/use-message-stream/test-harness.tsx,apps/desktop/src/app/session/hooks/use-session-actions.test.tsx,apps/desktop/src/app/session/hooks/use-session-actions/index.ts,apps/desktop/src/app/session/hooks/use-session-actions/restore-pending-clarify.test.ts,apps/desktop/src/app/session/hooks/use-session-actions/restore-pending-clarify.ts,apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx,apps/desktop/src/components/assistant-ui/clarify-tool.tsx,apps/desktop/src/components/assistant-ui/mcp-setup-tool.tsx,apps/desktop/src/components/assistant-ui/tool/approval.test.tsx(+66 more)Show the diff
-
apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx@@ -6,7 +6,6 @@ import { PaneVisibleContext } from '@/components/pane-shell/pane-visibility' import { $clarifyRequests } from '@/store/clarify' import type { ComposerAttachment } from '@/store/composer' import { clearQueuedPrompts, getQueuedPrompts } from '@/store/composer-queue' -import { $gateway } from '@/store/gateway' import { clearAllPrompts, hasBlockingPromptRequest, @@ -14,6 +13,7 @@ import { setSecretRequest, setSudoRequest } from '@/store/prompts' +import { hasOpenServerRequest, rememberServerRequest, resetServerRequestsForTests } from '@/store/server-requests' import { type ComposerTarget, requestComposerSubmit } from '../focus' import { ComposerScopeProvider, ComposerSurfaceProvider, MAIN_COMPOSER_SCOPE } from '../scope' @@ -457,26 +457,30 @@ describe('useComposerSubmit busy-turn routing', () => { }) describe('useComposerSubmit with a clarify parked on the session', () => { - const gatewayRequest = vi.fn(async () => ({ ok: true })) + // The clarify is a live server→client request: skipping it answers that + // request frame (`{ answer: '' }`), not a `clarify.respond` RPC. + const respond = vi.fn() const parkClarify = (sessionId: string) => { + const requestId = `req-${sessionId}` + + rememberServerRequest({ fail: vi.fn(), id: requestId, method: 'clarify', params: {}, respond }) $clarifyRequests.set({ [sessionId]: { - requestId: `req-${sessionId}`, + requestId, question: 'which one?', choices: ['a', 'b'], multiSelect: false, sessionId } }) - $gateway.set({ request: gatewayRequest } as unknown as ReturnType<typeof $gateway.get>) } afterEach(() => { cleanup() - gatewayRequest.mockClear() + respond.mockClear() + resetServerRequestsForTests() $clarifyRequests.set({}) - $gateway.set(null) vi.restoreAllMocks() }) @@ -488,16 +492,12 @@ describe('useComposerSubmit with a clarify parked on the session', () => { hook.result.current.submitDraft() }) - await waitFor(() => - expect(gatewayRequest).toHaveBeenCalledWith('clarify.respond', { - request_id: 'req-runtime-session', - answer: '' - }) - ) + await waitFor(() => expect(respond).toHaveBeenCalledWith({ answer: '' })) await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('actually do this instead', expect.objectContaining({ attachments: [] })) ) expect($clarifyRequests.get()['runtime-session']).toBeUndefined() + expect(hasOpenServerRequest('req-runtime-session')).toBe(false) }) it('skips the question before steering a busy turn', async () => { @@ -509,7 +509,7 @@ describe('useComposerSubmit with a clarify parked on the session', () => { }) await waitFor(() => expect(onSteer).toHaveBeenCalledWith('change course')) - expect(gatewayRequest).toHaveBeenCalledWith('clarify.respond', { request_id: 'req-runtime-session', answer: '' }) + expect(respond).toHaveBeenCalledWith({ answer: '' }) }) it('leaves the question alone for an empty Enter (Stop, not an answer)', () => { @@ -520,7 +520,8 @@ describe('useComposerSubmit with a clarify parked on the session', () => { hook.result.current.submitDraft() }) - expect(gatewayRequest).not.toHaveBeenCalled() + expect(respond).not.toHaveBeenCalled() + expect(hasOpenServerRequest('req-runtime-session')).toBe(true) expect($clarifyRequests.get()['runtime-session']).toBeDefined() expect(onCancel).toHaveBeenCalledTimes(1) }) @@ -534,7 +535,8 @@ describe('useComposerSubmit with a clarify parked on the session', () => { }) await waitFor(() => expect(onSubmit).toHaveBeenCalled()) - expect(gatewayRequest).not.toHaveBeenCalled() + expect(respond).not.toHaveBeenCalled() + expect(hasOpenServerRequest('req-other-session')).toBe(true) expect($clarifyRequests.get()['other-session']).toBeDefined() }) }) -
apps/desktop/src/app/contrib/wiring.tsx@@ -497,7 +497,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { [activeSessionIdRef, busyRef, selectedStoredSessionIdRef, updateSessionState] ) - const { handleGatewayEvent } = useMessageStream({ + const { handleGatewayEvent, handleServerRequest } = useMessageStream({ activeGatewayProfile, activeSessionIdRef, hydrateFromStoredSession, @@ -898,6 +898,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { closeAllTerminals() }, handleGatewayEvent: handleGatewayEventWithPlugins, + handleServerRequest, onConnectionReady: c => { connectionRef.current = c }, -
apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx@@ -258,6 +258,7 @@ function Harness({ useGatewayBoot({ beforeConnectionSwitch, handleGatewayEvent: () => undefined, + handleServerRequest: () => false, onConnectionReady: () => undefined, onGatewayReady: () => undefined, refreshHermesConfig, -
apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts@@ -2,6 +2,7 @@ import { type GatewayEvent, isGatewayReauthRequired, isGatewayWebSocketUrl, + JSON_RPC_METHOD_NOT_FOUND, JsonRpcGatewayError, reconnectBackoffDelayMs, resolveGatewayWsUrl @@ -31,6 +32,7 @@ import { closeLegacySecondaryGateways, closeSecondaryGateways, configureGatewayRegistry, + dispatchPrimaryServerRequest, disposeSecondariesForConnection, ensureActiveGatewayOpen, ensureGatewayForProfile, @@ -40,6 +42,7 @@ import { pruneSecondaryGateways, reconnectSecondaryGateways, reportPrimaryGatewayState, + type ScopedServerRequest, setPrimaryGateway, setPrimaryGatewayConnection, touchSecondaryGateways @@ -148,6 +151,8 @@ export function primaryRuntimeConnectionId(connection: Pick<HermesConnection, 'c interface GatewayBootOptions { beforeConnectionSwitch: () => void handleGatewayEvent: (event: GatewayEvent) => void + /** Server→client request from any registry socket; false = no handler (the channel answers -32601). */ + handleServerRequest: (request: ScopedServerRequest) => boolean onConnectionReady: ( connection: Awaited<ReturnType<NonNullable<typeof window.hermesDesktop>['getConnection']>> | null ) => void @@ -159,6 +164,7 @@ interface GatewayBootOptions { export function useGatewayBoot({ beforeConnectionSwitch, handleGatewayEvent, + handleServerRequest, onConnectionReady, onGatewayReady, refreshHermesConfig, @@ -167,6 +173,7 @@ export function useGatewayBoot({ const callbacksRef = useRef({ beforeConnectionSwitch, handleGatewayEvent, + handleServerRequest, onConnectionReady, onGatewayReady, refreshHermesConfig, @@ -176,6 +183,7 @@ export function useGatewayBoot({ callbacksRef.current = { beforeConnectionSwitch, handleGatewayEvent, + handleServerRequest, onConnectionReady, onGatewayReady, refreshHermesConfig, @@ -805,6 +813,11 @@ export function useGatewayBoot({ // (connectionId, profile) keep-set so two sources exposing the same // profile name (every source has a 'default') can't collide. configureGatewayRegistry({ + onServerRequest: request => { + if (!callbacksRef.current.handleServerRequest(request)) { + request.fail(JSON_RPC_METHOD_NOT_FOUND, `Hermes Desktop cannot answer ${request.method}`) + } + }, // The primary socket has no secondary entry to carry registry identity. // Electron's published active descriptor is authoritative after boot; // a true legacy primary has no connectionId and remains unqualified. @@ -910,6 +923,9 @@ export function useGatewayBoot({ callbacksRef.current.handleGatewayEvent(scopedEvent) }) + // Secondary sockets reach the same handler through the registry's onServerRequest. + const offRequest = gateway.onRequest(request => dispatchPrimaryServerRequest(request, sourceProfile)) + // Wake signals: power resume (macOS/Windows), network coming back, and the // window regaining focus/visibility. Each nudges an immediate reconnect. const forceReconnectNow = () => reconnectNow({ forceOpenSocket: true }) @@ -1274,6 +1290,7 @@ export function useGatewayBoot({ offActiveStateReauth() offState() offEvent() + offRequest() offExit() offWindowState?.() offBootProgress() -
apps/desktop/src/app/session/hooks/use-message-stream/clarify-hydration.test.tsx@@ -8,7 +8,7 @@ import { onScrollToBottomRequest } from '@/store/thread-scroll' import { type MessageStreamHarness, renderMessageStream } from './test-harness' -// A `clarify.request` must leave an answerable inline row even when the +// A `clarify` server request must leave an answerable inline row even when the // `tool.start` that normally mounts it was missed (stream reconnect / // hydration race). Without it the sidebar says "needs input" but the // transcript has nowhere to render the choices, so the agent blocks forever. @@ -24,8 +24,8 @@ function mountStream() { stream = renderMessageStream(SID) } -const clarifyRequest = (payload: Record<string, unknown>) => - act(() => stream.handleEvent({ payload, session_id: SID, type: 'clarify.request' })) +const clarifyRequest = ({ request_id, ...params }: Record<string, unknown>) => + act(() => void stream.handleRequest('clarify', { ...params, session_id: SID }, request_id as string)) const toolStart = (payload: Record<string, unknown>) => act(() => stream.handleEvent({ payload, session_id: SID, type: 'tool.start' })) @@ -34,7 +34,13 @@ const toolComplete = (payload: Record<string, unknown>) => act(() => stream.handleEvent({ payload, session_id: SID, type: 'tool.complete' })) const clarifyExpire = (requestId: string) => - act(() => stream.handleEvent({ payload: { request_id: requestId }, session_id: SID, type: 'clarify.expire' })) + act(() => + stream.handleEvent({ + payload: { id: requestId, method: 'clarify', reason: 'timeout' }, + session_id: SID, + type: 'request.cancel' + }) + ) function clarifyParts() { const messages = stream.state().messages ?? [] @@ -49,7 +55,7 @@ function seedHydratedMessages(messages: ChatMessage[]) { stream.states.set(SID, state) } -describe('clarify.request stream hydration', () => { +describe('clarify request stream hydration', () => { beforeEach(() => { clearClarifyRequest() scrollToBottom.mockClear() @@ -89,12 +95,13 @@ describe('clarify.request stream hydration', () => { it('does not move the active thread for a background session clarify', () => { mountStream() - act(() => - stream.handleEvent({ - payload: { choices: ['yes', 'no'], question: 'Ship it?', request_id: 'req-background' }, - session_id: 'session-background', - type: 'clarify.request' - }) + act( + () => + void stream.handleRequest( + 'clarify', + { choices: ['yes', 'no'], question: 'Ship it?', session_id: 'session-background' }, + 'req-background' + ) ) expect(scrollToBottom).not.toHaveBeenCalled() @@ -129,7 +136,7 @@ describe('clarify.request stream hydration', () => { it('merges with the real tool.start row even though its id differs from the request id', () => { mountStream() - // Reality: tool.start carries the model's tool_call_id, clarify.request a + // Reality: tool.start carries the model's tool_call_id, the clarify request a // separately-generated request_id. They must still collapse to ONE card // (correlated by question), not two. toolStart({ args: { choices: ['a'], question: 'Pick' }, name: 'clarify', tool_id: 'call-abc' }) -
/dev/null@@ -1,95 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' - -import { $gateway } from '@/store/gateway' -import { $toursEnabled } from '@/store/tours' - -import { handleDesktopBridgeEvent } from './desktop-bridge' -import type { GatewayEventContext } from './types' - -function previewActContext({ - explicitSid, - isActiveEvent -}: { - explicitSid: string - isActiveEvent: boolean -}): GatewayEventContext { - return { - event: { session_id: explicitSid || undefined, type: 'preview.act.request' }, - explicitSid, - isActiveEvent, - payload: { action: 'elements', request_id: 'request-1' } - } as GatewayEventContext -} - -describe('preview action bridge routing', () => { - afterEach(() => { - $gateway.set(null) - }) - - it('leaves a scoped action request unanswered in a window showing another session', () => { - const request = vi.fn() - $gateway.set({ request } as never) - - expect(handleDesktopBridgeEvent(previewActContext({ explicitSid: 'session-a', isActiveEvent: false }))).toBe(true) - expect(request).not.toHaveBeenCalled() - }) - - it('keeps the legacy fail-fast response for an unscoped inactive request', () => { - const request = vi.fn() - $gateway.set({ request } as never) - - expect(handleDesktopBridgeEvent(previewActContext({ explicitSid: '', isActiveEvent: false }))).toBe(true) - expect(request).toHaveBeenCalledWith('preview.act.respond', { - request_id: 'request-1', - text: JSON.stringify({ - error: 'The in-app browser only takes actions in the session the user is looking at.', - success: false - }) - }) - }) -}) - -function tourContext({ - explicitSid, - isActiveEvent -}: { - explicitSid: string - isActiveEvent: boolean -}): GatewayEventContext { - return { - event: { session_id: explicitSid || undefined, type: 'tour.request' }, - explicitSid, - isActiveEvent, - payload: { action: 'discover', request_id: 'tour-request-1' } - } as GatewayEventContext -} - -describe('tour bridge routing', () => { - afterEach(() => { - $gateway.set(null) - $toursEnabled.set(true) - }) - - it('leaves a scoped request unanswered in another session even when tours are disabled', () => { - const request = vi.fn() - $gateway.set({ request } as never) - $toursEnabled.set(false) - - expect(handleDesktopBridgeEvent(tourContext({ explicitSid: 'session-a', isActiveEvent: false }))).toBe(true) - expect(request).not.toHaveBeenCalled() - }) - - it('keeps the legacy fail-fast response for an unscoped inactive request', () => { - const request = vi.fn() - $gateway.set({ request } as never) - - expect(handleDesktopBridgeEvent(tourContext({ explicitSid: '', isActiveEvent: false }))).toBe(true) - expect(request).toHaveBeenCalledWith('tour.respond', { - request_id: 'tour-request-1', - text: JSON.stringify({ - error: 'Tours only run in the session the user is looking at.', - success: false - }) - }) - }) -}) -
apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/desktop-bridge.ts@@ -1,165 +1,17 @@ -import { readActivePreview } from '@/app/chat/right-rail/preview-reader' import { writeAgentTerminalChunk } from '@/app/right-sidebar/terminal/agent-terminal-stream' -import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer' import { closeAgentTerminalByProc } from '@/app/right-sidebar/terminal/terminals' -import type { PreviewActAction } from '@/lib/preview-act/act-in-page' -import type { TourAction, TourStep } from '@/lib/tour' -import { $gateway } from '@/store/gateway' import { applyDesktopLayoutPreset, revealDesktopPane } from '@/store/pane-focus' import { recordAgentReaction } from '@/store/reactions-local' import { setMessages } from '@/store/session' import { $tipsEnabled, type ActiveTip, showTip } from '@/store/tips' -import { $toursEnabled } from '@/store/tours' import type { GatewayEventContext } from './types' -/** The preview engine, loaded on demand so ~25KB of page-injectable source stays - * off the boot path. - * - * In dev that lazy chunk is also a trap. The browser caches a dynamic import by - * URL for the life of the page, so this bridge would hand every action to - * whichever build of the engine loaded first, and no edit to it — or to the - * overlay whose source it stringifies into the page — would reach the guest - * until the whole window reloaded. Asking for a fresh copy is more reliable - * than trusting hot-update propagation to reach a module nothing statically - * imports; the dev server stamps the dependency URLs it has invalidated, so a - * fresh engine pulls a fresh overlay down with it. - * - * The literal path is what a bare specifier can't be here, and it has to track - * this module's real location — hence the fall back to the static import, which - * is also the only branch production keeps, `import.meta.hot` being stripped - * there along with everything it guards. */ -const loadPreviewEngine = () => { - const stable = () => import('@/app/chat/right-rail/preview-act') - - if (!import.meta.hot) { - return stable().then(mod => mod.actOnActivePreview) - } - - return import(/* @vite-ignore */ '/src/app/chat/right-rail/preview-act.ts?hot=' + Date.now()) - .catch(stable) - .then(mod => mod.actOnActivePreview as Awaited<ReturnType<typeof stable>>['actOnActivePreview']) -} - -/** Desktop-surface bridge events: read-back requests the agent blocks on - * (terminal/preview/window), agent terminal streaming, pane reveal, and - * message reactions. */ +/** Desktop-surface bridge events: agent terminal streaming, tips, pane + * reveal, layouts and message reactions. The read-back REQUESTS the agent + * blocks on (terminal/preview/window/tour) live in `server-requests.ts`. */ export function handleDesktopBridgeEvent(ctx: GatewayEventContext): boolean { - const { event, payload, explicitSid, isActiveEvent } = ctx - - if (event.type === 'terminal.read.request') { - // read_terminal tool: serialize the renderer's xterm buffer and answer - // immediately (Python blocks on the respond). Empty text = no live pane. - const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' - - if (requestId) { - const start = typeof payload?.start === 'number' ? payload.start : undefined - const count = typeof payload?.count === 'number' ? payload.count : undefined - const result = readActiveTerminal({ start, count }) - - void $gateway.get()?.request('terminal.read.respond', { - request_id: requestId, - text: result ? JSON.stringify(result) : '' - }) - } - - return true - } - - if (event.type === 'preview.read.request') { - // read_preview tool: serialize the active preview tab (a Browser - // webview's page text is async) and answer. Empty text = nothing open. - const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' - - if (requestId) { - const start = typeof payload?.start === 'number' ? payload.start : undefined - const count = typeof payload?.count === 'number' ? payload.count : undefined - - void readActivePreview({ count, start }).then(result => { - void $gateway.get()?.request('preview.read.respond', { - request_id: requestId, - text: result ? JSON.stringify(result) : '' - }) - }) - } - - return true - } - - if (event.type === 'preview.act.request') { - // drive_preview tool: click/type/scroll/press inside the guest page, or - // drive the pane's history. Dynamic import keeps the injected engine off - // the boot path. Active session only: a background turn must never reach - // into the page the user is working in (desktop AGENTS.md: offer, don't - // hijack). - const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' - - if (requestId) { - // Every mounted desktop window can observe the same gateway event. A - // scoped mismatch belongs to another window, so answering here would race - // the owning window and could make this refusal win before its real result. - if (explicitSid && !isActiveEvent) { - return true - } - - const answer = (result: unknown) => - $gateway.get()?.request('preview.act.respond', { - request_id: requestId, - text: result ? JSON.stringify(result) : '' - }) - - if (isActiveEvent) { - void loadPreviewEngine() - .then(run => - run({ - amount: payload?.amount, - key: payload?.key, - kind: payload?.action ?? '', - max: payload?.max, - ref: payload?.ref, - selector: payload?.selector, - submit: payload?.submit, - text: payload?.text, - to: payload?.to as PreviewActAction['to'] - }) - ) - .then(answer, error => - answer({ error: error instanceof Error ? error.message : String(error), success: false }) - ) - } else { - void answer({ - error: 'The in-app browser only takes actions in the session the user is looking at.', - success: false - }) - } - } - - return true - } - - if (event.type === 'window.read.request') { - // read_window_below tool: main owns native window enumeration, so ask - // it over IPC and answer. Empty text = unavailable (no bridge, or - // enumeration unsupported on this system e.g. Wayland). - const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' - - if (requestId) { - const read = window.hermesDesktop?.readWindowBelow - - const answer = (result: unknown) => - $gateway.get()?.request('window.read.respond', { - request_id: requestId, - text: result ? JSON.stringify(result) : '' - }) - - // .catch: ipcRenderer.invoke rejects on an older shell without the - // handler or a main-side throw — without an empty answer the tool - // would stall its full 30s timeout. - void Promise.resolve(read ? read() : null).then(answer, () => answer(null)) - } - - return true - } + const { event, payload, isActiveEvent } = ctx if (event.type === 'agent.terminal.output') { // Live chunk from a background process → its read-only agent terminal tab. @@ -176,64 +28,6 @@ export function handleDesktopBridgeEvent(ctx: GatewayEventContext): boolean { return true } - if (event.type === 'tour.request') { - // tour tool: run one guided-tour action (highlight/step/discover) via - // driver.js — on the app's own DOM or inside the preview pane's guest - // page — and answer with the outcome. Dynamic import keeps driver.js - // and the preview injection payload off the boot path. Active session - // only: a background turn must never paint overlays on the user's - // screen (desktop AGENTS.md: offer, don't hijack). - const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' - - if (requestId) { - // As with preview actions, only the renderer that owns an explicitly - // scoped request may answer. Inactive windows must stay silent even when - // tours are disabled locally, or their refusal can beat the owner. - if (explicitSid && !isActiveEvent) { - return true - } - - const answer = (result: unknown) => - $gateway.get()?.request('tour.respond', { - request_id: requestId, - text: result ? JSON.stringify(result) : '' - }) - - if (!$toursEnabled.get()) { - // Refused in words, not silently dropped: the agent asked for a - // walkthrough it isn't getting, and a no-op would leave it narrating - // a spotlight the user can't see. - void answer({ error: 'The user has turned guided tours off.', success: false }) - } else if (isActiveEvent) { - void import('@/lib/tour') - .then(({ runTour }) => - runTour( - { - kind: (payload?.action ?? 'stop') as TourAction['kind'], - selector: payload?.selector, - side: payload?.side as TourStep['side'], - startAt: payload?.step_index, - steps: payload?.steps as TourStep[] | undefined, - text: payload?.text, - title: payload?.title - }, - payload?.surface === 'preview' ? 'preview' : 'app' - ) - ) - .then(answer, error => - answer({ error: error instanceof Error ? error.message : String(error), success: false }) - ) - } else { - void answer({ - error: 'Tours only run in the session the user is looking at.', - success: false - }) - } - } - - return true - } - if (event.type === 'tip.show') { // tip tool: point the accent bubble at something and say one line about // it. Fire-and-forget — a tip is not a question, and blocking the turn on -
apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/input-requests.ts@@ -1,448 +1,89 @@ import { pendingClarifyToolPayload } from '@/app/session/hooks/use-session-actions/restore-pending-clarify' -import { translateNow } from '@/i18n' -import { restorePendingClarifyToolCall, settlePendingClarifyToolCall } from '@/lib/chat-messages' -import { - $clarifyRequests, - clearClarifyRequest, - normalizeChoices, - normalizeQuestions, - setClarifyRequest, - warnDroppedChoices -} from '@/store/clarify' -import { $gateway } from '@/store/gateway' -import { setMcpSetupRequest } from '@/store/mcp-setup' -import { dispatchNativeNotification } from '@/store/native-notifications' +import { settlePendingClarifyToolCall } from '@/lib/chat-messages' +import { $clarifyRequests, clearClarifyRequest } from '@/store/clarify' +import { $mcpSetupRequests, clearMcpSetupRequest } from '@/store/mcp-setup' import { + $approvalRequests, + $secretRequests, + $sudoRequests, $vaultCodeRequests, $vaultSaveLoginRequests, $vaultUnlockRequests, + clearApprovalRequest, + clearSecretRequest, + clearSudoRequest, clearVaultCodeRequest, clearVaultSaveLoginRequest, - clearVaultUnlockRequest, - receiveApprovalRequest, - setSecretRequest, - setSudoRequest, - setVaultCodeRequest, - setVaultSaveLoginRequest, - setVaultUnlockRequest + clearVaultUnlockRequest } from '@/store/prompts' -import { requestScrollToBottom } from '@/store/thread-scroll' +import { forgetServerRequest } from '@/store/server-requests' import type { GatewayEventContext } from './types' -/** The blocking-input family: clarify / MCP setup consent / approval / sudo / - * secret requests. The Python side is blocked on the matching *.respond, so - * each of these must be parked per-session and surfaced. */ +/** The blocking-input family arrives as server→client REQUESTS (see + * `server-requests.ts`); the one EVENT in the family is `request.cancel`, the + * backend withdrawing an open request (timeout / interrupt / session close): + * tear down whichever parked card carries that id. Cancel is request-correlated: + * a delayed cancel for an older prompt must not erase a newer one the same + * session raised. */ export function handleInputRequestEvent(ctx: GatewayEventContext): boolean { const { deps, event, payload, sessionId, occurredAt } = ctx - const { activeSessionIdRef, sessionInterrupted, updateSessionState, upsertToolCall } = deps - - if (event.type === 'clarify.request') { - // Surface the clarify tool's overlay. The Python side is blocked on - // `clarify.respond`, so without this handler the agent would hang - // forever (see tools/clarify_tool.py + tui_gateway/server.py:_block). - // - // Store the request for whichever session raised it — even a background - // one. clarify.request is a one-shot event; if we dropped it for an - // unfocused session, that session would block on `clarify.respond` - // indefinitely and re-focusing it could never recover (the event is - // gone). Parking it per-session lets the user answer once they switch - // over; the inline ClarifyTool reads the active session's entry. - if (sessionId && sessionInterrupted(sessionId)) { - return true - } - - const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' - const question = typeof payload?.question === 'string' ? payload.question : '' - const rawChoices = payload?.choices - const choices = normalizeChoices(rawChoices) - const multiSelect = payload?.multi_select === true - // Batch (multi-question) clarify: `questions` replaces question/choices - // on the wire. `answers` rides along only on reconnect replay, carrying - // the per-question locks the server already accepted. - const questions = normalizeQuestions(payload?.questions) - - const lockedAnswers = - typeof payload?.answers === 'object' && payload?.answers !== null - ? Object.fromEntries( - Object.entries(payload.answers as Record<string, unknown>).filter( - (entry): entry is [string, string] => typeof entry[1] === 'string' - ) - ) - : undefined - - if (requestId && questions.length > 0) { - const request = { - choices: null, - lockedAnswers, - multiSelect: false, - question: '', - questions, - receivedAt: Date.now() / 1000, - requestId, - sessionId: sessionId ?? null - } - - setClarifyRequest(request) - - if (sessionId) { - // A resumed/hydrated transcript may already contain this provider's - // clarify call while carrying no live streamId. Re-arm that exact row - // instead of letting the generic stream mutator append a second card. - updateSessionState(sessionId, state => { - const projection = restorePendingClarifyToolCall( - state.messages, - pendingClarifyToolPayload(request), - occurredAt - ) - - return { - ...state, - messages: projection.messages, - streamId: projection.streamId, - sawAssistantPayload: true, - awaitingResponse: false, - needsInput: true - } - }) - - if (sessionId === activeSessionIdRef.current) { - requestScrollToBottom(sessionId) - } - } - - dispatchNativeNotification({ - body: questions.map(q => q.question).join(' · '), - kind: 'input', - sessionId, - title: translateNow('notifications.native.inputTitle') - }) - } else if (requestId && question) { - if (rawChoices != null && choices.length === 0) { - warnDroppedChoices('gateway', question, rawChoices) - } - const request = { - requestId, - question, - choices: choices.length > 0 ? choices : null, - multiSelect, - receivedAt: Date.now() / 1000, - sessionId: sessionId ?? null - } - - setClarifyRequest(request) - - if (sessionId) { - // Same provider-shape-aware repair as the batch path above. This keeps - // the original hydrated row and provider tool id, while still seeding - // a row when tool.start was genuinely missed. - updateSessionState(sessionId, state => { - const projection = restorePendingClarifyToolCall( - state.messages, - pendingClarifyToolPayload(request), - occurredAt - ) - - return { - ...state, - messages: projection.messages, - streamId: projection.streamId, - sawAssistantPayload: true, - awaitingResponse: false, - needsInput: true - } - }) - - if (sessionId === activeSessionIdRef.current) { - requestScrollToBottom(sessionId) - } - } - - dispatchNativeNotification({ - body: question, - kind: 'input', - sessionId, - title: translateNow('notifications.native.inputTitle') - }) - } - - return true + if (event.type !== 'request.cancel') { + return false } - if (event.type === 'vault.code.expire') { - const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' - const request = sessionId ? $vaultCodeRequests.get()[sessionId] : undefined - - if (requestId && request && request.requestId === requestId) { - clearVaultCodeRequest(sessionId, requestId) - } + const id = typeof payload?.id === 'string' ? payload.id : '' + if (!id) { return true } - if (event.type === 'vault.save_login.expire') { - const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' - const request = sessionId ? $vaultSaveLoginRequests.get()[sessionId] : undefined + forgetServerRequest(id) - if (requestId && request && request.requestId === requestId) { - clearVaultSaveLoginRequest(sessionId, requestId) - } + const key = sessionId ?? '' - return true - } - - if (event.type === 'vault.unlock.expire') { - const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' - const request = sessionId ? $vaultUnlockRequests.get()[sessionId] : undefined - - if (requestId && request && request.requestId === requestId) { - clearVaultUnlockRequest(sessionId, requestId) - } - - return true - } - - if (event.type === 'clarify.expire') { - if (!sessionId) { - return true - } - - const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' - const request = $clarifyRequests.get()[sessionId] - - // Expiry is request-correlated: a delayed event from an older prompt must - // not erase a newer clarify raised by the same session. - if (!requestId || !request || request.requestId !== requestId) { - return true - } + if ($clarifyRequests.get()[key]?.requestId === id) { + const request = $clarifyRequests.get()[key] - clearClarifyRequest(requestId, sessionId) - updateSessionState(sessionId, state => { - const projection = settlePendingClarifyToolCall( - state.messages, - pendingClarifyToolPayload(request), - state.busy, - occurredAt - ) + clearClarifyRequest(id, sessionId) - return { - ...state, - messages: projection.messages, - needsInput: false, - streamId: state.busy ? (projection.streamId ?? state.streamId) : null - } - }) - - return true - } - - if (event.type === 'mcp.setup.request') { - // setup_mcp tool (desktop GUI): the agent proposed an MCP server and - // the Python side is blocked on mcp.setup.respond. Park the request - // per-session (like clarify) and upsert a stable pending tool row so - // the inline consent card has somewhere to render even when the - // tool.start event was missed (stream reconnect / hydration race). - const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' - const server = typeof payload?.server === 'string' ? payload.server : '' - const rawAction = typeof payload?.action === 'string' ? payload.action : 'install' - const action = rawAction === 'enable' || rawAction === 'authorize' ? rawAction : 'install' - const reason = typeof payload?.reason === 'string' ? payload.reason : '' - - if (requestId && server) { - setMcpSetupRequest({ action, reason, requestId, server, sessionId: sessionId ?? null }) - - if (sessionId) { - upsertToolCall( - sessionId, - { args: { action, reason, server }, name: 'setup_mcp', tool_id: requestId }, - 'running' + if (sessionId && request) { + deps.updateSessionState(sessionId, state => { + const projection = settlePendingClarifyToolCall( + state.messages, + pendingClarifyToolPayload(request), + state.busy, + occurredAt ) - updateSessionState(sessionId, state => ({ ...state, needsInput: true })) - } - - dispatchNativeNotification({ - body: reason || server, - kind: 'input', - sessionId, - title: translateNow('notifications.native.inputTitle') - }) - } - - return true - } - if (event.type === 'approval.request') { - // Dangerous-command / execute_code approval. The Python side is blocked - // in _await_gateway_decision() until approval.respond lands; without - // this the agent stalls until its 5-min timeout and the tool is BLOCKED. - // Park it per-session (like clarify) so a *background* profile's turn can - // raise it and wait — the sidebar flags "needs input" and the inline bar - // surfaces once the user focuses that chat. - const command = typeof payload?.command === 'string' ? payload.command : '' - const description = typeof payload?.description === 'string' ? payload.description : 'dangerous command' - - void receiveApprovalRequest($gateway.get(), { - // false only when a tirith warning forbids it; backend omits the field otherwise. - allowPermanent: payload?.allow_permanent !== false, - choices: Array.isArray(payload?.choices) - ? payload.choices.filter(choice => typeof choice === 'string') - : undefined, - command, - description, - requestId: typeof payload?.request_id === 'string' ? payload.request_id : undefined, - sessionId: sessionId ?? null, - smartDenied: payload?.smart_denied === true - }).catch(() => undefined) - - if (sessionId) { - updateSessionState(sessionId, state => ({ ...state, needsInput: true })) - } - - dispatchNativeNotification({ - actions: [ - { id: 'approve', text: translateNow('notifications.native.approveAction') }, - { id: 'reject', text: translateNow('notifications.native.rejectAction') } - ], - body: command || description, - kind: 'approval', - sessionId, - title: translateNow('notifications.native.approvalTitle') - }) - - return true - } - - if (event.type === 'sudo.request') { - // Sudo password capture (tools/terminal_tool.py). Blocked on - // sudo.respond {request_id, password}. - const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' - - if (requestId) { - setSudoRequest({ requestId, sessionId: sessionId ?? null }) - - if (sessionId) { - updateSessionState(sessionId, state => ({ ...state, needsInput: true })) - } - - dispatchNativeNotification({ - body: translateNow('notifications.native.inputBody'), - kind: 'input', - sessionId, - title: translateNow('notifications.native.inputTitle') - }) - } - - return true - } - - if (event.type === 'secret.request') { - // Skill credential capture (tools/skills_tool.py). Blocked on - // secret.respond {request_id, value}. - const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' - - if (requestId) { - const envVar = typeof payload?.env_var === 'string' ? payload.env_var : '' - const promptText = typeof payload?.prompt === 'string' ? payload.prompt : '' - - setSecretRequest({ - requestId, - envVar, - prompt: promptText, - sessionId: sessionId ?? null - }) - - if (sessionId) { - updateSessionState(sessionId, state => ({ ...state, needsInput: true })) - } - - dispatchNativeNotification({ - body: promptText || envVar || translateNow('notifications.native.inputBody'), - kind: 'input', - sessionId, - title: translateNow('notifications.native.inputTitle') - }) - } - - return true - } - - if (event.type === 'vault.code.request') { - // Second factor: the site asked for a one-time code and no authenticator key is saved for the login. - const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' - - if (requestId) { - const site = typeof payload?.site === 'string' ? payload.site : '' - const hint = typeof payload?.hint === 'string' ? payload.hint : '' - - setVaultCodeRequest({ hint, requestId, sessionId: sessionId ?? null, site }) - - if (sessionId) { - updateSessionState(sessionId, state => ({ ...state, needsInput: true })) - } - - dispatchNativeNotification({ - body: translateNow('prompts.vaultCodeTitle', site), - kind: 'input', - sessionId, - title: translateNow('notifications.native.inputTitle') - }) - } - - return true - } - - if (event.type === 'vault.save_login.request') { - // The agent is on a sign-in page with no saved login: identifier + masked password card; the - // answer is stored in the encrypted vault by the backend and filled at once (never shown to the model). - const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' - - if (requestId) { - const origin = typeof payload?.origin === 'string' ? payload.origin : '' - const site = typeof payload?.site === 'string' ? payload.site : origin - - setVaultSaveLoginRequest({ origin, requestId, sessionId: sessionId ?? null, site }) - - if (sessionId) { - updateSessionState(sessionId, state => ({ ...state, needsInput: true })) - } - - dispatchNativeNotification({ - body: translateNow('prompts.vaultSaveTitle', site), - kind: 'input', - sessionId, - title: translateNow('notifications.native.inputTitle') + return { + ...state, + messages: projection.messages, + needsInput: false, + streamId: state.busy ? (projection.streamId ?? state.streamId) : null + } }) } return true } - if (event.type === 'vault.unlock.request') { - // External password-manager unlock (agent/vault_backends). Blocked on - // vault.unlock.respond {request_id, password}; "" keeps it locked. - const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' - - if (requestId) { - const backend = typeof payload?.backend === 'string' ? payload.backend : '' - const displayName = typeof payload?.display_name === 'string' ? payload.display_name : backend - - setVaultUnlockRequest({ backend, displayName, requestId, sessionId: sessionId ?? null }) - - if (sessionId) { - updateSessionState(sessionId, state => ({ ...state, needsInput: true })) - } - - dispatchNativeNotification({ - body: translateNow('prompts.vaultUnlockTitle', displayName), - kind: 'input', - sessionId, - title: translateNow('notifications.native.inputTitle') - }) - } - - return true + if ($approvalRequests.get()[key]?.serverRequestId === id) { + clearApprovalRequest(sessionId, $approvalRequests.get()[key]?.requestId) + } else if ($sudoRequests.get()[key]?.requestId === id) { + clearSudoRequest(sessionId, id) + } else if ($secretRequests.get()[key]?.requestId === id) { + clearSecretRequest(sessionId, id) + } else if ($vaultCodeRequests.get()[key]?.requestId === id) { + clearVaultCodeRequest(sessionId, id) + } else if ($vaultSaveLoginRequests.get()[key]?.requestId === id) { + clearVaultSaveLoginRequest(sessionId, id) + } else if ($vaultUnlockRequests.get()[key]?.requestId === id) { + clearVaultUnlockRequest(sessionId, id) + } else if ($mcpSetupRequests.get()[key]?.requestId === id) { + clearMcpSetupRequest(id, sessionId) } - return false + return true } -
apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/server-requests.test.ts@@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { $toursEnabled } from '@/store/tours' + +import { handleServerRequest } from './server-requests' +import type { ServerRequestContext } from './server-requests' + +const deps = {} as ServerRequestContext['deps'] + +function deliver(method: string, params: Record<string, unknown>, activeSessionId: null | string) { + const respond = vi.fn() + const fail = vi.fn() + const handled = handleServerRequest({ fail, id: 'srq-1', method, params, profile: 'default', respond }, deps, activeSessionId) + + return { fail, handled, respond } +} + +describe('preview action request routing', () => { + it('leaves a scoped action request unanswered in a window showing another session', () => { + const { handled, respond, fail } = deliver('preview.act', { action: 'elements', session_id: 'session-a' }, 'session-b') + + expect(handled).toBe(true) + expect(respond).not.toHaveBeenCalled() + expect(fail).not.toHaveBeenCalled() + }) + + it('fails fast for an unscoped request with no session in view', () => { + const { respond } = deliver('preview.act', { action: 'elements' }, null) + + expect(respond).toHaveBeenCalledWith({ + value: JSON.stringify({ + error: 'The in-app browser only takes actions in the session the user is looking at.', + success: false + }) + }) + }) +}) + +describe('tour request routing', () => { + afterEach(() => { + $toursEnabled.set(true) + }) + + it('leaves a scoped request unanswered in another session even when tours are disabled', () => { + $toursEnabled.set(false) + const { handled, respond } = deliver('tour', { action: 'discover', session_id: 'session-a' }, 'session-b') + + expect(handled).toBe(true) + expect(respond).not.toHaveBeenCalled() + }) + + it('fails fast for an unscoped request with no session in view', () => { + const { respond } = deliver('tour', { action: 'discover' }, null) + + expect(respond).toHaveBeenCalledWith({ + value: JSON.stringify({ error: 'Tours only run in the session the user is looking at.', success: false }) + }) + }) +}) -
apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/server-requests.ts@@ -0,0 +1,434 @@ +import { readActivePreview } from '@/app/chat/right-rail/preview-reader' +import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer' +import { pendingClarifyToolPayload } from '@/app/session/hooks/use-session-actions/restore-pending-clarify' +import { translateNow } from '@/i18n' +import { restorePendingClarifyToolCall } from '@/lib/chat-messages' +import type { PreviewActAction } from '@/lib/preview-act/act-in-page' +import type { TourAction, TourStep } from '@/lib/tour' +import { normalizeChoices, normalizeQuestions, setClarifyRequest, warnDroppedChoices } from '@/store/clarify' +import type { ScopedServerRequest } from '@/store/gateway' +import { setMcpSetupRequest } from '@/store/mcp-setup' +import { dispatchNativeNotification } from '@/store/native-notifications' +import { + receiveApprovalRequest, + setSecretRequest, + setSudoRequest, + setVaultCodeRequest, + setVaultSaveLoginRequest, + setVaultUnlockRequest +} from '@/store/prompts' +import { rememberServerRequest } from '@/store/server-requests' +import { requestScrollToBottom } from '@/store/thread-scroll' +import { $toursEnabled } from '@/store/tours' + +import type { GatewayEventDeps } from './types' + +/** The preview engine, loaded on demand so ~25KB of page-injectable source stays + * off the boot path (dev: a fresh copy per action so edits reach the guest — see + * the previous home of this loader in desktop-bridge.ts for the full story). */ +const loadPreviewEngine = () => { + const stable = () => import('@/app/chat/right-rail/preview-act') + + if (!import.meta.hot) { + return stable().then(mod => mod.actOnActivePreview) + } + + return import(/* @vite-ignore */ '/src/app/chat/right-rail/preview-act.ts?hot=' + Date.now()) + .catch(stable) + .then(mod => mod.actOnActivePreview as Awaited<ReturnType<typeof stable>>['actOnActivePreview']) +} + +const str = (v: unknown): string => (typeof v === 'string' ? v : '') +const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined) + +/** Answer a string-valued request with a JSON-encoded result ('' = nothing / unavailable). */ +const answerValue = (request: ScopedServerRequest, result: unknown) => + request.respond({ value: result ? JSON.stringify(result) : '' }) + +export interface ServerRequestContext { + deps: Pick<GatewayEventDeps, 'activeSessionIdRef' | 'sessionInterrupted' | 'updateSessionState' | 'upsertToolCall'> + request: ScopedServerRequest + /** The session the request names ('' when unscoped). */ + sessionId: string + /** The named session is the one on screen. */ + isActiveSession: boolean +} + +type Handler = (ctx: ServerRequestContext) => void + +const markNeedsInput = (ctx: ServerRequestContext) => { + if (ctx.sessionId) { + ctx.deps.updateSessionState(ctx.sessionId, state => ({ ...state, needsInput: true })) + } +} + +const notifyInput = (ctx: ServerRequestContext, body: string) => { + if (!ctx.request.replayed) { + dispatchNativeNotification({ + body, + kind: 'input', + sessionId: ctx.sessionId || null, + title: translateNow('notifications.native.inputTitle') + }) + } +} + +// ── Blocking-input family (clarify / approval / sudo / secret / vault / MCP setup) ── +// Every one is parked per-session (like clarify) so a BACKGROUND session's turn can +// raise it and wait — the sidebar flags "needs input" and the card surfaces once the +// user focuses that chat. The Python side blocks on the response frame; without a +// handler the channel answers -32601 and the tool fails fast instead of stalling. + +const clarify: Handler = ctx => { + const { deps, request, sessionId } = ctx + const p = request.params + + if (sessionId && deps.sessionInterrupted(sessionId)) { + request.respond({ answer: '' }) + + return + } + + const question = str(p.question) + const rawChoices = p.choices + const choices = normalizeChoices(rawChoices) + const multiSelect = p.multi_select === true + // Batch (multi-question) clarify: `questions` replaces question/choices on the + // wire. `answers` rides along only on a reconnect replay (locks the server + // already accepted). + const questions = normalizeQuestions(p.questions) + + const lockedAnswers = + typeof p.answers === 'object' && p.answers !== null + ? Object.fromEntries( + Object.entries(p.answers as Record<string, unknown>).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' + ) + ) + : undefined + + if (questions.length === 0 && !question) { + request.respond({ answer: '' }) + + return + } + + if (questions.length === 0 && rawChoices != null && choices.length === 0) { + warnDroppedChoices('gateway', question, rawChoices) + } + + const clarifyRequest = + questions.length > 0 + ? { + choices: null, + lockedAnswers, + multiSelect: false, + question: '', + questions, + receivedAt: Date.now() / 1000, + requestId: request.id, + sessionId: sessionId || null + } + : { + choices: choices.length > 0 ? choices : null, + multiSelect, + question, + receivedAt: Date.now() / 1000, + requestId: request.id, + sessionId: sessionId || null + } + + rememberServerRequest(request) + setClarifyRequest(clarifyRequest) + + if (sessionId) { + // A resumed/hydrated transcript may already contain this provider's clarify + // call while carrying no live streamId. Re-arm that exact row instead of + // letting the generic stream mutator append a second card. + const occurredAt = Date.now() / 1000 + + deps.updateSessionState(sessionId, state => { + const projection = restorePendingClarifyToolCall( + state.messages, + pendingClarifyToolPayload(clarifyRequest), + occurredAt + ) + + return { + ...state, + messages: projection.messages, + streamId: projection.streamId, + sawAssistantPayload: true, + awaitingResponse: false, + needsInput: true + } + }) + + if (sessionId === deps.activeSessionIdRef.current) { + requestScrollToBottom(sessionId) + } + } + + notifyInput(ctx, questions.length > 0 ? questions.map(q => q.question).join(' · ') : question) +} + +const approval: Handler = ctx => { + const { request, sessionId } = ctx + const p = request.params + const command = str(p.command) + const description = str(p.description) || 'dangerous command' + + rememberServerRequest(request) + void receiveApprovalRequest(null, { + // false only when a tirith warning forbids it; backend omits the field otherwise. + allowPermanent: p.allow_permanent !== false, + choices: Array.isArray(p.choices) ? p.choices.filter((choice): choice is string => typeof choice === 'string') : undefined, + command, + description, + // The approval queue's own id — `approval.pending` / `approval.received` / `approval.respond` key on it. + requestId: str(p.request_id) || undefined, + serverRequestId: request.id, + sessionId: sessionId || null, + smartDenied: p.smart_denied === true + }).catch(() => undefined) + markNeedsInput(ctx) + + if (!request.replayed) { + dispatchNativeNotification({ + actions: [ + { id: 'approve', text: translateNow('notifications.native.approveAction') }, + { id: 'reject', text: translateNow('notifications.native.rejectAction') } + ], + body: command || description, + kind: 'approval', + sessionId: sessionId || null, + title: translateNow('notifications.native.approvalTitle') + }) + } +} + +const sudo: Handler = ctx => { + rememberServerRequest(ctx.request) + setSudoRequest({ requestId: ctx.request.id, sessionId: ctx.sessionId || null }) + markNeedsInput(ctx) + notifyInput(ctx, translateNow('notifications.native.inputBody')) +} + +const secret: Handler = ctx => { + const p = ctx.request.params + const envVar = str(p.env_var) + const promptText = str(p.prompt) + + rememberServerRequest(ctx.request) + setSecretRequest({ envVar, prompt: promptText, requestId: ctx.request.id, sessionId: ctx.sessionId || null }) + markNeedsInput(ctx) + notifyInput(ctx, promptText || envVar || translateNow('notifications.native.inputBody')) +} + +const vaultCode: Handler = ctx => { + const p = ctx.request.params + const site = str(p.site) + + rememberServerRequest(ctx.request) + setVaultCodeRequest({ hint: str(p.hint), requestId: ctx.request.id, sessionId: ctx.sessionId || null, site }) + markNeedsInput(ctx) + notifyInput(ctx, translateNow('prompts.vaultCodeTitle', site)) +} + +const vaultSaveLogin: Handler = ctx => { + const p = ctx.request.params + const origin = str(p.origin) + const site = str(p.site) || origin + + rememberServerRequest(ctx.request) + setVaultSaveLoginRequest({ origin, requestId: ctx.request.id, sessionId: ctx.sessionId || null, site }) + markNeedsInput(ctx) + notifyInput(ctx, translateNow('prompts.vaultSaveTitle', site)) +} + +const vaultUnlockPrompt: Handler = ctx => { + const p = ctx.request.params + const backend = str(p.backend) + const displayName = str(p.display_name) || backend + + rememberServerRequest(ctx.request) + setVaultUnlockRequest({ backend, displayName, requestId: ctx.request.id, sessionId: ctx.sessionId || null }) + markNeedsInput(ctx) + notifyInput(ctx, translateNow('prompts.vaultUnlockTitle', displayName)) +} + +const mcpSetup: Handler = ctx => { + // setup_mcp tool (desktop GUI): the agent proposed an MCP server. Park the + // request per-session (like clarify) and upsert a stable pending tool row so + // the inline consent card has somewhere to render even when the tool.start + // event was missed (stream reconnect / hydration race). + const { deps, request, sessionId } = ctx + const p = request.params + const server = str(p.server) + const rawAction = str(p.action) || 'install' + const action = rawAction === 'enable' || rawAction === 'authorize' ? rawAction : 'install' + const reason = str(p.reason) + + if (!server) { + request.respond({ value: '' }) + + return + } + + rememberServerRequest(request) + setMcpSetupRequest({ action, reason, requestId: request.id, server, sessionId: sessionId || null }) + + if (sessionId) { + deps.upsertToolCall(sessionId, { args: { action, reason, server }, name: 'setup_mcp', tool_id: request.id }, 'running') + } + + markNeedsInput(ctx) + notifyInput(ctx, reason || server) +} + +// ── Desktop-surface bridges (answered immediately, no card) ───────────────── + +const terminalRead: Handler = ({ request }) => { + // read_terminal tool: serialize the renderer's xterm buffer. Empty = no live pane. + answerValue(request, readActiveTerminal({ count: num(request.params.count), start: num(request.params.start) })) +} + +const previewRead: Handler = ({ request }) => { + // read_preview tool: the active preview tab's page text is async. Empty = nothing open. + void readActivePreview({ count: num(request.params.count), start: num(request.params.start) }).then(result => + answerValue(request, result) + ) +} + +const previewAct: Handler = ({ isActiveSession, request, sessionId }) => { + // drive_preview tool: click/type/scroll/press inside the guest page. Active + // session only: a background turn must never reach into the page the user is + // working in (desktop AGENTS.md: offer, don't hijack). Every mounted window can + // observe the same request; a scoped mismatch belongs to another window, so + // answering here would race the owner — stay silent. + if (sessionId && !isActiveSession) { + return + } + + const p = request.params + + if (!isActiveSession) { + answerValue(request, { + error: 'The in-app browser only takes actions in the session the user is looking at.', + success: false + }) + + return + } + + void loadPreviewEngine() + .then(run => + run({ + amount: p.amount as never, + key: p.key as never, + kind: (str(p.action) || '') as never, + max: p.max as never, + ref: p.ref as never, + selector: p.selector as never, + submit: p.submit as never, + text: p.text as never, + to: p.to as PreviewActAction['to'] + }) + ) + .then( + result => answerValue(request, result), + error => answerValue(request, { error: error instanceof Error ? error.message : String(error), success: false }) + ) +} + +const windowRead: Handler = ({ request }) => { + // read_window_below tool: main owns native window enumeration. Empty = + // unavailable (older shell without the handler, Wayland, …) — without an + // answer the tool would stall its full 30s deadline. + const read = window.hermesDesktop?.readWindowBelow + + void Promise.resolve(read ? read() : null).then( + result => answerValue(request, result), + () => answerValue(request, null) + ) +} + +const tour: Handler = ({ isActiveSession, request, sessionId }) => { + // tour tool: one guided-tour action via driver.js, app DOM or preview guest + // page. Active session only, same window-ownership rule as preview.act. + if (sessionId && !isActiveSession) { + return + } + + const p = request.params + + if (!$toursEnabled.get()) { + // Refused in words, not silently dropped: a no-op would leave the agent + // narrating a spotlight the user can't see. + answerValue(request, { error: 'The user has turned guided tours off.', success: false }) + + return + } + + if (!isActiveSession) { + answerValue(request, { error: 'Tours only run in the session the user is looking at.', success: false }) + + return + } + + void import('@/lib/tour') + .then(({ runTour }) => + runTour( + { + kind: (str(p.action) || 'stop') as TourAction['kind'], + selector: p.selector as never, + side: p.side as TourStep['side'], + startAt: p.step_index as never, + steps: p.steps as TourStep[] | undefined, + text: p.text as never, + title: p.title as never + }, + p.surface === 'preview' ? 'preview' : 'app' + ) + ) + .then( + result => answerValue(request, result), + error => answerValue(request, { error: error instanceof Error ? error.message : String(error), success: false }) + ) +} + +/** Method → handler. Every `ServerRequestMap` key the desktop answers. */ +export const SERVER_REQUEST_HANDLERS: Record<string, Handler> = { + approval, + clarify, + 'mcp.setup': mcpSetup, + 'preview.act': previewAct, + 'preview.read': previewRead, + secret, + sudo, + 'terminal.read': terminalRead, + tour, + 'vault.code': vaultCode, + 'vault.save_login': vaultSaveLogin, + 'vault.unlock_prompt': vaultUnlockPrompt, + 'window.read': windowRead +} + +/** Dispatch one server→client request; false when the desktop has no handler for its method. */ +export function handleServerRequest( + request: ScopedServerRequest, + deps: ServerRequestContext['deps'], + activeSessionId: null | string +): boolean { + const handler = SERVER_REQUEST_HANDLERS[request.method] + + if (!handler) { + return false + } + + const sessionId = str(request.params.session_id) + + handler({ deps, request, sessionId, isActiveSession: Boolean(sessionId) && sessionId === activeSessionId }) + + return true +} -
apps/desktop/src/app/session/hooks/use-message-stream/index.ts@@ -24,6 +24,7 @@ import { stripGeneratedImageEchoes } from '@/lib/generated-images' import { isTodoToolName, nextTodosFromToolEvent, parseTodoRevision } from '@/lib/todos' +import type { ScopedServerRequest } from '@/store/gateway' import { dispatchNativeNotification } from '@/store/native-notifications' import { isDiskFullErrorMessage, notifyError } from '@/store/notifications' import { broadcastSessionsChanged } from '@/store/session-sync' @@ -33,6 +34,7 @@ import { $todosBySession, setSessionTodos } from '@/store/todos' import type { ClientSessionState } from '../../../types' import { useGatewayEventHandler } from './gateway-event' +import { handleServerRequest as dispatchServerRequest } from './gateway-event/server-requests' import { completionErrorText, delegateTaskPayloads, MAX_STREAM_FLUSH_GAP_MS, STREAM_DELTA_FLUSH_MS } from './utils' interface MessageStreamOptions { @@ -880,11 +882,25 @@ export function useMessageStream({ upsertToolCall }) + // Server→client requests (clarify, approval, sudo, …) from every socket the + // registry owns. The request answers itself over the socket it arrived on, + // so no owner routing is involved here — only which card to show. + const handleServerRequest = useCallback( + (request: ScopedServerRequest): boolean => + dispatchServerRequest( + request, + { activeSessionIdRef, sessionInterrupted, updateSessionState, upsertToolCall }, + activeSessionIdRef.current + ), + [activeSessionIdRef, sessionInterrupted, updateSessionState, upsertToolCall] + ) + return { appendAssistantDelta, appendReasoningDelta, completeAssistantMessage, handleGatewayEvent, + handleServerRequest, finalizeInterimAssistantMessage, upsertToolCall } -
apps/desktop/src/app/session/hooks/use-message-stream/test-harness.tsx@@ -6,6 +6,7 @@ import { vi } from 'vitest' import type { ClientSessionState } from '@/app/types' import { createClientSessionState } from '@/lib/chat-runtime' +import type { ScopedServerRequest } from '@/store/gateway' import { useMessageStream } from './index' @@ -17,6 +18,9 @@ export interface MessageStreamHarnessOptions extends Partial<Parameters<typeof u export interface MessageStreamHarness { /** Feed a gateway event into the mounted hook. */ handleEvent: (event: GatewayEvent) => void + /** Feed a server→client request (clarify, approval, …) into the mounted hook; + * returns the `respond` spy so a test can assert the answer frame. */ + handleRequest: (method: string, params: Record<string, unknown>, id?: string) => ReturnType<typeof vi.fn> /** Push streaming assistant text, bypassing the event envelope. For the specs * about flush scheduling rather than about a particular event. */ appendDelta: (sessionId: string, delta: string) => void @@ -48,6 +52,7 @@ export function renderMessageStream( { states = new Map<string, ClientSessionState>(), ...overrides }: MessageStreamHarnessOptions = {} ): MessageStreamHarness { let dispatch: ((event: GatewayEvent) => void) | null = null + let dispatchRequest: ((request: ScopedServerRequest) => boolean) | null = null let appendDelta: ((sessionId: string, delta: string) => void) | null = null let latest: ClientSessionState | null = null @@ -75,8 +80,9 @@ export function renderMessageStream( useEffect(() => { dispatch = stream.handleGatewayEvent + dispatchRequest = stream.handleServerRequest appendDelta = stream.appendAssistantDelta - }, [stream.appendAssistantDelta, stream.handleGatewayEvent]) + }, [stream.appendAssistantDelta, stream.handleGatewayEvent, stream.handleServerRequest]) return null } @@ -93,6 +99,17 @@ export function renderMessageStream( dispatch(event) }, + handleRequest: (method, params, id = `srq-${method}`) => { + const respond = vi.fn() + + if (!dispatchRequest) { + throw new Error('renderMessageStream: the hook never mounted') + } + + dispatchRequest({ fail: vi.fn(), id, method, params, profile: 'default', respond }) + + return respond + }, appendDelta: (id, delta) => { if (!appendDelta) { throw new Error('renderMessageStream: the hook never mounted') -
apps/desktop/src/app/session/hooks/use-session-actions.test.tsx@@ -1136,16 +1136,26 @@ describe('resumeSession failure recovery', () => { const requestGateway = vi.fn(async (method: string) => { if (method === 'session.resume') { + // The channel re-delivers `open_requests` to the request handler BEFORE + // the caller sees the result; that handler parks the card. Mirror that + // ordering here (no channel in this harness). + setClarifyRequest({ + choices: ['safe', 'fast'], + multiSelect: false, + question: 'Which path?', + receivedAt: Date.now(), + requestId: 'req-resumed', + sessionId: 'runtime-1' + }) + return { info: {}, message_count: 2, messages: [], messages_omitted: true, - pending_clarify: { - choices: ['safe', 'fast'], - question: 'Which path?', - request_id: 'req-resumed' - }, + open_requests: [ + { id: 'req-resumed', method: 'clarify', params: { choices: ['safe', 'fast'], question: 'Which path?' } } + ], resumed: 'stored-1', running: true, session_id: 'runtime-1', @@ -1196,21 +1206,34 @@ describe('resumeSession failure recovery', () => { session_id: 'stored-1' } as never) + const questions = [ + { choices: ['Blue', 'Red'], qid: 'q0', question: 'Color?' }, + { choices: ['Small', 'Large'], qid: 'q1', question: 'Size?' } + ] + const requestGateway = vi.fn(async (method: string) => { if (method === 'session.resume') { + // Request handler parks the batch card (with the server-locked answer) + // from the re-delivered open request before the result lands. + setClarifyRequest({ + choices: null, + lockedAnswers: { q0: 'Blue' }, + multiSelect: false, + question: '', + questions: questions.map(q => ({ ...q, multiSelect: false })), + receivedAt: Date.now(), + requestId: 'req-batch-resumed', + sessionId: 'runtime-1' + }) + return { info: {}, message_count: 2, messages: [], messages_omitted: true, - pending_clarify: { - answers: { q0: 'Blue' }, - questions: [ - { choices: ['Blue', 'Red'], qid: 'q0', question: 'Color?' }, - { choices: ['Small', 'Large'], qid: 'q1', question: 'Size?' } - ], - request_id: 'req-batch-resumed' - }, + open_requests: [ + { id: 'req-batch-resumed', method: 'clarify', params: { answers: { q0: 'Blue' }, questions } } + ], resumed: 'stored-1', running: true, session_id: 'runtime-1', @@ -2900,16 +2923,23 @@ describe('resumeSession warm-cache mapping integrity', () => { const requestGateway = vi.fn(async (method: string) => { if (method === 'session.activate') { + setClarifyRequest({ + choices: ['safe', 'fast'], + multiSelect: false, + question: 'Which path?', + receivedAt: Date.now(), + requestId: 'req-warm', + sessionId: 'rt-A' + }) + return { info: {}, message_count: 2, messages: [], messages_omitted: true, - pending_clarify: { - choices: ['safe', 'fast'], - question: 'Which path?', - request_id: 'req-warm' - }, + open_requests: [ + { id: 'req-warm', method: 'clarify', params: { choices: ['safe', 'fast'], question: 'Which path?' } } + ], resumed: 'stored-A', running: true, session_id: 'rt-A', -
apps/desktop/src/app/session/hooks/use-session-actions/index.ts@@ -50,7 +50,7 @@ import { resolveNewChatOwnerRoute } from '@/store/profile' import { $projectScope, resolveNewSessionCwd } from '@/store/projects' -import { setApprovalRequest } from '@/store/prompts' +import { receiveApprovalRequest } from '@/store/prompts' import { clearStoredTranscriptReadOnly, markStoredTranscriptReadOnly } from '@/store/read-only-transcript' import { $activeSessionStoredIdRotation, @@ -345,7 +345,10 @@ function restorePendingApproval(response: SessionResumeResponse, sessionId: stri return false } - setApprovalRequest({ + // The live `approval` server request (re-delivered from `open_requests` + // before this ran) already parked itself with the same queue id; don't + // clobber it with a copy that can only answer through the RPC fallback. + void receiveApprovalRequest(null, { allowPermanent: pending.allow_permanent !== false, choices: pending.choices, command: pending.command ?? '', -
apps/desktop/src/app/session/hooks/use-session-actions/restore-pending-clarify.test.ts@@ -30,102 +30,41 @@ describe('restorePendingClarifyFromSnapshot', () => { $clarifyRequests.set({}) }) - it('restores a batch clarify snapshot (questions, no top-level question)', () => { - const state = restorePendingClarifyFromSnapshot( - { - pending_clarify: { - request_id: 'rid1', - questions: [ - { choices: ['Yes', 'No'], multi_select: false, qid: 'q0', question: 'Proceed?' }, - { qid: 'q1', question: 'Which region?' } - ] - } - }, - 'sess-1', - resumeStartedAt - ) - - expect(state.request).not.toBeNull() - expect(setClarifyRequestMock).toHaveBeenCalledWith( - expect.objectContaining({ + it('hands back the card the request handler already parked for a replayed open clarify request', () => { + $clarifyRequests.set({ + 'sess-1': { + choices: null, multiSelect: false, - question: '', + question: 'Proceed?', + receivedAt: resumeStartedAt + 5, requestId: 'rid1', - sessionId: 'sess-1', - questions: [ - { choices: ['Yes', 'No'], multiSelect: false, qid: 'q0', question: 'Proceed?' }, - { multiSelect: false, qid: 'q1', question: 'Which region?', choices: null } - ] - }) - ) - }) - - it('carries server-locked answers into the replayed batch card', () => { - restorePendingClarifyFromSnapshot( - { - pending_clarify: { - answers: { q0: 'Yes', junk: 42 }, - request_id: 'rid2', - questions: [{ qid: 'q0', question: 'Proceed?' }] - } - }, - 'sess-2', - resumeStartedAt - ) - - expect(setClarifyRequestMock).toHaveBeenCalledWith( - expect.objectContaining({ lockedAnswers: { q0: 'Yes' }, requestId: 'rid2' }) - ) - }) + sessionId: 'sess-1' + } + }) - it('still restores the single-question form', () => { const state = restorePendingClarifyFromSnapshot( - { - pending_clarify: { - choices: ['A', 'B'], - multi_select: true, - question: 'Pick one', - request_id: 'rid3' - } - }, - 'sess-3', + { open_requests: [{ id: 'rid1', method: 'clarify', params: { question: 'Proceed?' } }] }, + 'sess-1', resumeStartedAt ) - expect(state.request).not.toBeNull() - expect(setClarifyRequestMock).toHaveBeenCalledWith( - expect.objectContaining({ - choices: ['A', 'B'], - multiSelect: true, - question: 'Pick one', - requestId: 'rid3' - }) - ) + expect(state.authoritativeAbsent).toBe(false) + expect(state.request?.requestId).toBe('rid1') + expect(clearClarifyRequestMock).not.toHaveBeenCalled() }) - it('rejects a payload with neither form (no request restored)', () => { + it('reports an open request the handler declined (no card parked) without inventing one', () => { const state = restorePendingClarifyFromSnapshot( - { pending_clarify: { request_id: 'rid4' } }, + { open_requests: [{ id: 'rid4', method: 'clarify', params: {} }] }, 'sess-4', resumeStartedAt ) + expect(state.authoritativeAbsent).toBe(false) expect(state.request).toBeNull() expect(setClarifyRequestMock).not.toHaveBeenCalled() }) - it('rejects a payload with no request id', () => { - const state = restorePendingClarifyFromSnapshot( - { pending_clarify: { question: 'Orphaned prompt' } }, - 'sess-5', - resumeStartedAt - ) - - expect(state.request).toBeNull() - expect(state.authoritativeAbsent).toBe(true) - expect(setClarifyRequestMock).not.toHaveBeenCalled() - }) - it('clears a stale local request when the snapshot has none, and leaves a newer in-flight one', () => { $clarifyRequests.set({ 'sess-6': { -
apps/desktop/src/app/session/hooks/use-session-actions/restore-pending-clarify.ts@@ -1,12 +1,5 @@ import type { GatewayEventPayload } from '@/lib/chat-messages' -import { - $clarifyRequests, - type ClarifyRequest, - clearClarifyRequest, - normalizeChoices, - normalizeQuestions, - setClarifyRequest -} from '@/store/clarify' +import { $clarifyRequests, type ClarifyRequest, clearClarifyRequest } from '@/store/clarify' import type { SessionResumeResponse } from '@/types/hermes' export interface PendingClarifyResumeState { @@ -16,28 +9,26 @@ export interface PendingClarifyResumeState { } /** - * Restore a pending clarify from a resume/activate snapshot onto `sessionId`. + * Reconcile the parked clarify for `sessionId` against a resume/activate + * snapshot. * - * The snapshot mirrors the live clarify.request wire shape: single-question - * payloads carry `question`/`choices`/`multi_select`; batch (multi-question) - * ones carry `questions` (+ any answers already locked server-side) and no - * top-level `question`. Multi-select locks arrive as JSON-encoded arrays - * inside a string — never a bare array — so `lockedAnswers` keeps string - * values only. - * - * A missing snapshot is authoritative only for requests that already existed - * when the RPC began. A newer clarify.request that arrives while the response - * is in flight is left alone. + * The snapshot's `open_requests` names every server→client request still + * blocking the session. The shared channel has ALREADY re-delivered those to + * the request handlers (which parked the clarify card) before the caller sees + * the response, so this only has to (a) report the parked request when the + * snapshot confirms it and (b) treat a snapshot WITHOUT a clarify as + * authoritative for requests that already existed when the RPC began — a + * newer request that arrived while the response was in flight is left alone. */ export function restorePendingClarifyFromSnapshot( - response: Pick<SessionResumeResponse, 'pending_clarify'>, + response: Pick<SessionResumeResponse, 'open_requests'>, sessionId: string, resumeStartedAt: number, requestIdAtStart?: string ): PendingClarifyResumeState { - const pending = response.pending_clarify + const pending = (response.open_requests ?? []).find(entry => entry.method === 'clarify') - if (!pending || typeof pending.request_id !== 'string') { + if (!pending) { const current = $clarifyRequests.get()[sessionId] const existedAtStart = Boolean(current && requestIdAtStart && current.requestId === requestIdAtStart) @@ -53,36 +44,12 @@ export function restorePendingClarifyFromSnapshot( return { authoritativeAbsent: true, cleared: null, request: null } } - const questions = normalizeQuestions(pending.questions) - const question = typeof pending.question === 'string' ? pending.question : '' - - if (!question && questions.length === 0) { - return { authoritativeAbsent: false, cleared: null, request: null } - } - - const choices = normalizeChoices(pending.choices) - - const lockedAnswers = - typeof pending.answers === 'object' && pending.answers !== null - ? Object.fromEntries( - Object.entries(pending.answers).filter((entry): entry is [string, string] => typeof entry[1] === 'string') - ) - : undefined - - const request: ClarifyRequest = { - choices: choices.length > 0 ? choices : null, - lockedAnswers, - multiSelect: pending.multi_select === true, - question, - receivedAt: Date.now() / 1000, - requestId: pending.request_id, - sessionId, - ...(questions.length > 0 ? { questions } : {}) - } - - setClarifyRequest(request) + // The request handler parked it under this session when the channel + // re-delivered `open_requests`; a card the handler declined (empty + // question) is simply not there. + const parked = $clarifyRequests.get()[sessionId] - return { authoritativeAbsent: false, cleared: null, request } + return { authoritativeAbsent: false, cleared: null, request: parked?.requestId === pending.id ? parked : null } } export function pendingClarifyToolPayload(request: ClarifyRequest): GatewayEventPayload { -
apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx@@ -2,7 +2,7 @@ import type { ToolCallMessagePartProps } from '@assistant-ui/react' import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { atom } from 'nanostores' import type { ReactNode } from 'react' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { onComposerInsertRequest } from '@/app/chat/composer/focus' import { type SessionView, SessionViewProvider } from '@/app/chat/session-view' @@ -12,6 +12,7 @@ import { I18nProvider } from '@/i18n' import { clearClarifyRequest, setClarifyRequest } from '@/store/clarify' import { $gateway } from '@/store/gateway' import { $profiles } from '@/store/profile' +import { hasOpenServerRequest, rememberServerRequest, resetServerRequestsForTests } from '@/store/server-requests' import { $activeSessionId, _resetSessionOwnerHintsForTests, setSessionOwnerHint } from '@/store/session' import { ClarifyTool, readClarifyBatchResult, readClarifyResult } from './clarify-tool' @@ -38,9 +39,23 @@ vi.mock('@assistant-ui/react', () => ({ useAuiState: () => messageRunning })) +/** Seed a live `clarify` server request; the card answers it synchronously with + * `respondToServerRequest`, so the returned spy sees the response frame. */ +function liveServerRequest(id: string) { + const respond = vi.fn() + rememberServerRequest({ fail: vi.fn(), id, method: 'clarify', params: {}, respond }) + + return respond +} + +beforeEach(() => { + resetServerRequestsForTests() +}) + afterEach(() => { cleanup() clearClarifyRequest() + resetServerRequestsForTests() $activeSessionId.set(null) $gateway.set(null) messageRunning = true @@ -99,6 +114,7 @@ function liveClarifyProps(choices = ['staging', 'production']): ToolCallMessageP function renderLiveClarify({ multiSelect = false }: { multiSelect?: boolean } = {}) { const request = vi.fn().mockResolvedValue({ ok: true }) + const respond = liveServerRequest('request-1') $activeSessionId.set('session-1') $gateway.set({ request } as never) @@ -111,7 +127,7 @@ function renderLiveClarify({ multiSelect = false }: { multiSelect?: boolean } = }) const { rerender } = renderClarify(<ClarifyTool {...liveClarifyProps()} />) - return { request, rerender } + return { request, rerender, respond } } describe('ClarifyTool live card stays mounted across settle', () => { @@ -135,13 +151,13 @@ describe('ClarifyTool live card stays mounted across settle', () => { }) it('holds the card through the gap between answering and the settled result', async () => { - const { request, rerender } = renderLiveClarify() + const { rerender, respond } = renderLiveClarify() fireEvent.click(screen.getByRole('button', { name: /staging/ })) fireEvent.click(screen.getByRole('button', { name: /Continue/ })) await waitFor(() => { - expect(request).toHaveBeenCalled() + expect(respond).toHaveBeenCalled() }) // tool.complete is what swaps in the settled card; the turn can already @@ -177,7 +193,7 @@ describe('ClarifyTool live card stays mounted across settle', () => { describe('ClarifyTool choice selection', () => { it('selects independently, deselects and submits multi-select choices as a JSON array', async () => { - const { request } = renderLiveClarify({ multiSelect: true }) + const { respond } = renderLiveClarify({ multiSelect: true }) const staging = screen.getByRole('button', { name: /staging/ }) const production = screen.getByRole('button', { name: /production/ }) @@ -197,15 +213,12 @@ describe('ClarifyTool choice selection', () => { fireEvent.click(screen.getByRole('button', { name: /Continue/ })) await waitFor(() => { - expect(request).toHaveBeenCalledWith('clarify.respond', { - answer: JSON.stringify(['production', 'staging']), - request_id: 'request-1' - }) + expect(respond).toHaveBeenCalledWith({ answer: JSON.stringify(['production', 'staging']) }) }) }) it('keeps single-select replacement and plain-string submission', async () => { - const { request } = renderLiveClarify() + const { respond } = renderLiveClarify() const staging = screen.getByRole('button', { name: /staging/ }) const production = screen.getByRole('button', { name: /production/ }) @@ -218,11 +231,9 @@ describe('ClarifyTool choice selection', () => { fireEvent.click(screen.getByRole('button', { name: /Continue/ })) await waitFor(() => { - expect(request).toHaveBeenCalledWith('clarify.respond', { - answer: 'production', - request_id: 'request-1' - }) + expect(respond).toHaveBeenCalledWith({ answer: 'production' }) }) + expect(hasOpenServerRequest('request-1')).toBe(false) }) }) @@ -397,36 +408,30 @@ describe('ClarifyTool keyboard navigation', () => { }) it('selects by number and confirms the answer with Enter', async () => { - const { request } = renderLiveClarify() + const { respond } = renderLiveClarify() fireEvent.keyDown(window, { key: '2' }) fireEvent.keyDown(window, { key: 'Enter' }) await waitFor(() => { - expect(request).toHaveBeenCalledWith('clarify.respond', { - answer: 'production', - request_id: 'request-1' - }) + expect(respond).toHaveBeenCalledWith({ answer: 'production' }) }) }) it('stages a highlighted multi-select choice with Enter and submits it with Continue', async () => { - const { request } = renderLiveClarify({ multiSelect: true }) + const { respond } = renderLiveClarify({ multiSelect: true }) const production = screen.getByRole('button', { name: /production/ }) fireEvent.keyDown(window, { key: 'ArrowDown' }) fireEvent.keyDown(window, { key: 'Enter' }) expect(production.getAttribute('aria-pressed')).toBe('true') - expect(request).not.toHaveBeenCalled() + expect(respond).not.toHaveBeenCalled() fireEvent.click(screen.getByRole('button', { name: /Continue/ })) await waitFor(() => { - expect(request).toHaveBeenCalledWith('clarify.respond', { - answer: JSON.stringify(['production']), - request_id: 'request-1' - }) + expect(respond).toHaveBeenCalledWith({ answer: JSON.stringify(['production']) }) }) }) @@ -445,23 +450,23 @@ describe('ClarifyTool keyboard navigation', () => { }) it('does not intercept keyboard events while an action button has focus', () => { - const { request } = renderLiveClarify() + const { respond } = renderLiveClarify() const skip = screen.getByRole('button', { name: 'Skip' }) skip.focus() expect(fireEvent.keyDown(window, { key: 'Enter' })).toBe(true) expect(fireEvent.keyDown(window, { key: 'ArrowDown' })).toBe(true) - expect(request).not.toHaveBeenCalled() + expect(respond).not.toHaveBeenCalled() }) }) describe('ClarifyTool recommended option', () => { it('dims the (Recommended) label and answers with the choice the backend sent', async () => { - const request = vi.fn().mockResolvedValue({ ok: true }) + const respond = liveServerRequest('request-1') $activeSessionId.set('session-1') - $gateway.set({ request } as never) + $gateway.set({ request: vi.fn() } as never) setClarifyRequest({ choices: ['staging (Recommended)', 'production'], multiSelect: false, @@ -482,10 +487,7 @@ describe('ClarifyTool recommended option', () => { // The decorated string goes back verbatim; the tool strips the label before // the agent ever sees the answer. await waitFor(() => { - expect(request).toHaveBeenCalledWith('clarify.respond', { - answer: 'staging (Recommended)', - request_id: 'request-1' - }) + expect(respond).toHaveBeenCalledWith({ answer: 'staging (Recommended)' }) }) }) }) @@ -564,6 +566,7 @@ function liveBatchProps(): ToolCallMessagePartProps { function renderLiveBatch(lockedAnswers?: Record<string, string>, multiSelect = false) { const request = vi.fn().mockResolvedValue({ ok: true, remaining: [] }) + const respond = liveServerRequest('request-batch') $activeSessionId.set('session-1') $gateway.set({ request } as never) @@ -581,7 +584,7 @@ function renderLiveBatch(lockedAnswers?: Record<string, string>, multiSelect = f }) renderClarify(<ClarifyTool {...liveBatchProps()} />) - return request + return { request, respond } } describe('readClarifyBatchResult', () => { @@ -611,23 +614,20 @@ describe('readClarifyBatchResult', () => { describe('ClarifyTool submit shortcut', () => { it('submits selected choices with Cmd/Ctrl+Enter without toggling the focused option', async () => { for (const modifier of [{ metaKey: true }, { ctrlKey: true }]) { - const { request } = renderLiveClarify({ multiSelect: true }) + const { respond } = renderLiveClarify({ multiSelect: true }) const choice = screen.getByRole('button', { name: /staging/ }) fireEvent.click(choice) choice.focus() fireEvent.keyDown(choice, { key: 'Enter', ...modifier }) - await waitFor(() => expect(request).toHaveBeenCalledTimes(1)) - expect(request).toHaveBeenCalledWith('clarify.respond', { - request_id: 'request-1', - answer: '["staging"]' - }) + await waitFor(() => expect(respond).toHaveBeenCalledTimes(1)) + expect(respond).toHaveBeenCalledWith({ answer: '["staging"]' }) cleanup() } }) it('submits a complete batch from its text field while preserving incomplete and multiline input', async () => { for (const modifier of [{ metaKey: true }, { ctrlKey: true }]) { - const request = renderLiveBatch() + const { request } = renderLiveBatch() const field = screen.getByPlaceholderText('Type your answer…') fireEvent.change(field, { target: { value: 'packet' } }) field.focus() @@ -639,7 +639,7 @@ describe('ClarifyTool submit shortcut', () => { expect(request).not.toHaveBeenCalled() fireEvent.keyDown(field, { key: 'Enter', ...modifier }) await waitFor(() => expect(request).toHaveBeenCalledTimes(2)) - expect(request).toHaveBeenNthCalledWith(2, 'clarify.respond', { + expect(request).toHaveBeenNthCalledWith(2, 'clarify.lock', { answer: 'packet', question_id: 'q1', request_id: 'request-batch' @@ -659,7 +659,7 @@ describe('ClarifyTool batch card', () => { }) it('stages locally and keeps the single confirm disabled until all answered', async () => { - const request = renderLiveBatch() + const { request } = renderLiveBatch() const confirm = screen.getByRole('button', { name: /Confirm and continue/ }) expect((confirm as HTMLButtonElement).disabled).toBe(true) @@ -676,8 +676,8 @@ describe('ClarifyTool batch card', () => { expect((confirm as HTMLButtonElement).disabled).toBe(false) }) - it('confirm sends every per-question lock in order and completes the batch', async () => { - const request = renderLiveBatch() + it('confirm sends every per-question clarify.lock in order and completes the batch', async () => { + const { request } = renderLiveBatch() fireEvent.click(screen.getByRole('button', { name: /red/ })) fireEvent.change(screen.getByPlaceholderText('Type your answer…'), { target: { value: 'packet' } }) @@ -686,20 +686,22 @@ describe('ClarifyTool batch card', () => { await waitFor(() => { expect(request).toHaveBeenCalledTimes(2) }) - expect(request).toHaveBeenNthCalledWith(1, 'clarify.respond', { + expect(request).toHaveBeenNthCalledWith(1, 'clarify.lock', { answer: 'red', question_id: 'q0', request_id: 'request-batch' }) - expect(request).toHaveBeenNthCalledWith(2, 'clarify.respond', { + expect(request).toHaveBeenNthCalledWith(2, 'clarify.lock', { answer: 'packet', question_id: 'q1', request_id: 'request-batch' }) + // The last lock resolves the server request; the card forgets it locally. + await waitFor(() => expect(hasOpenServerRequest('request-batch')).toBe(false)) }) it('a staged answer stays editable before confirm', async () => { - const request = renderLiveBatch() + const { request } = renderLiveBatch() fireEvent.click(screen.getByRole('button', { name: /red/ })) fireEvent.click(screen.getByRole('button', { name: /blue/ })) @@ -710,7 +712,7 @@ describe('ClarifyTool batch card', () => { expect(request).toHaveBeenCalledTimes(2) }) // The re-pick won: blue, not red. - expect(request).toHaveBeenNthCalledWith(1, 'clarify.respond', { + expect(request).toHaveBeenNthCalledWith(1, 'clarify.lock', { answer: 'blue', question_id: 'q0', request_id: 'request-batch' @@ -732,17 +734,15 @@ describe('ClarifyTool batch card', () => { expect(screen.getByText('1 of 2 answered')).toBeTruthy() }) - it('Skip cancels the whole batch without a question_id', async () => { - const request = renderLiveBatch() + it('Skip cancels the whole batch with an empty response (no answers)', async () => { + const { request, respond } = renderLiveBatch() fireEvent.click(screen.getByRole('button', { name: 'Skip' })) await waitFor(() => { - expect(request).toHaveBeenCalledWith('clarify.respond', { - answer: '', - request_id: 'request-batch' - }) + expect(respond).toHaveBeenCalledWith({}) }) + expect(request).not.toHaveBeenCalled() }) it('renders the settled batch with all questions and answers', () => { @@ -773,7 +773,10 @@ describe('ClarifyTool batch card', () => { // foreground focus, so after a profile / Bot Chat switch it can be profile B // while the blocking clarify belongs to profile A — the response lands on a // backend that never held the request and the owner stays blocked until the -// tool times out. Every live clarify.respond now routes by request.sessionId. +// tool times out. A clarify answer is now the server request's own response +// frame — it rides the socket the request arrived on, so no routing decision is +// left to make. Only `clarify.lock` (a real client→server RPC) still dials a +// socket, and it must route by request.sessionId. const OWNER_CONNECTION_ID = 'conn-profile-a' const OWNER_PROFILE = 'profile-a' @@ -794,12 +797,12 @@ function armCrossProfileOwner() { return ambient } -function expectOwnerCall(nth: number, params: Record<string, unknown>) { +function expectOwnerLock(nth: number, params: Record<string, unknown>) { expect(gatewayMocks.requestGatewayForAgent).toHaveBeenNthCalledWith( nth, OWNER_CONNECTION_ID, OWNER_PROFILE, - 'clarify.respond', + 'clarify.lock', params ) } @@ -811,8 +814,9 @@ describe('ClarifyTool owner routing', () => { gatewayMocks.requestGatewayForAgent.mockClear() }) - it('answers a single clarify on the owner socket, never profile B ambient', async () => { + it('answers a single clarify through its server request, never profile B ambient', async () => { const ambient = armCrossProfileOwner() + const respond = liveServerRequest('request-1') setClarifyRequest({ choices: ['staging', 'production'], @@ -827,14 +831,15 @@ describe('ClarifyTool owner routing', () => { fireEvent.click(screen.getByRole('button', { name: /Continue/ })) await waitFor(() => { - expect(gatewayMocks.requestGatewayForAgent).toHaveBeenCalledTimes(1) + expect(respond).toHaveBeenCalledWith({ answer: 'staging' }) }) - expectOwnerCall(1, { answer: 'staging', request_id: 'request-1' }) + expect(gatewayMocks.requestGatewayForAgent).not.toHaveBeenCalled() expect(ambient).not.toHaveBeenCalled() }) it('sends both sequential batch locks on the owner socket, in order', async () => { const ambient = armCrossProfileOwner() + liveServerRequest('request-batch') setClarifyRequest({ choices: null, @@ -857,13 +862,14 @@ describe('ClarifyTool owner routing', () => { expect(gatewayMocks.requestGatewayForAgent).toHaveBeenCalledTimes(2) }) // The LAST lock resolves the blocked tool, so order is load-bearing. - expectOwnerCall(1, { answer: 'red', question_id: 'q0', request_id: 'request-batch' }) - expectOwnerCall(2, { answer: 'packet', question_id: 'q1', request_id: 'request-batch' }) + expectOwnerLock(1, { answer: 'red', question_id: 'q0', request_id: 'request-batch' }) + expectOwnerLock(2, { answer: 'packet', question_id: 'q1', request_id: 'request-batch' }) expect(ambient).not.toHaveBeenCalled() }) - it('sends a batch skip/cancel on the owner socket', async () => { + it('answers a batch skip/cancel through its server request, never profile B ambient', async () => { const ambient = armCrossProfileOwner() + const respond = liveServerRequest('request-batch') setClarifyRequest({ choices: null, @@ -881,9 +887,9 @@ describe('ClarifyTool owner routing', () => { fireEvent.click(screen.getByRole('button', { name: 'Skip' })) await waitFor(() => { - expect(gatewayMocks.requestGatewayForAgent).toHaveBeenCalledTimes(1) + expect(respond).toHaveBeenCalledWith({}) }) - expectOwnerCall(1, { answer: '', request_id: 'request-batch' }) + expect(gatewayMocks.requestGatewayForAgent).not.toHaveBeenCalled() expect(ambient).not.toHaveBeenCalled() }) }) @@ -915,7 +921,11 @@ describe('ClarifyTool visible-card scoping', () => { return { ...liveClarifyProps(), args, argsText: JSON.stringify(args), toolCallId } } + /** Park a clarify on `sessionId` with its own live server request; the + * returned spy sees that request's (and only that request's) answer. */ function parkClarify(requestId: string, sessionId: string) { + const respond = liveServerRequest(requestId) + setClarifyRequest({ choices: ['staging', 'production'], multiSelect: false, @@ -923,6 +933,8 @@ describe('ClarifyTool visible-card scoping', () => { requestId, sessionId }) + + return respond } /** A card inside an inactive tab layer — mounted and live, just not on screen. */ @@ -937,11 +949,9 @@ describe('ClarifyTool visible-card scoping', () => { } it('answers the visible card, not a background one that mounted first', async () => { - const request = vi.fn().mockResolvedValue({ ok: true }) - - $gateway.set({ request } as never) - parkClarify(BACKGROUND_REQUEST, BACKGROUND_SESSION) - parkClarify(FOREGROUND_REQUEST, FOREGROUND_SESSION) + $gateway.set({ request: vi.fn() } as never) + const background = parkClarify(BACKGROUND_REQUEST, BACKGROUND_SESSION) + const foreground = parkClarify(FOREGROUND_REQUEST, FOREGROUND_SESSION) // The background card is rendered FIRST, so its window listener registers // first. Registration order used to decide the winner, which meant the card @@ -958,29 +968,25 @@ describe('ClarifyTool visible-card scoping', () => { fireEvent.keyDown(window, { key: 'Enter' }) await waitFor(() => { - expect(request).toHaveBeenCalledTimes(1) + expect(foreground).toHaveBeenCalledTimes(1) }) - // Exactly one answer, carrying the FOREGROUND request id — the background - // session's turn must not be resumed by a keystroke aimed at this one. - expect(request).toHaveBeenCalledWith('clarify.respond', { - answer: 'staging', - request_id: FOREGROUND_REQUEST - }) + // Exactly one answer, on the FOREGROUND request — the background session's + // turn must not be resumed by a keystroke aimed at this one. + expect(foreground).toHaveBeenCalledWith({ answer: 'staging' }) + expect(background).not.toHaveBeenCalled() }) it('leaves the key alone when the only pending card is hidden', () => { - const request = vi.fn().mockResolvedValue({ ok: true }) - - $gateway.set({ request } as never) - parkClarify(BACKGROUND_REQUEST, BACKGROUND_SESSION) + $gateway.set({ request: vi.fn() } as never) + const background = parkClarify(BACKGROUND_REQUEST, BACKGROUND_SESSION) renderClarify(backgroundCard()) // Untouched (no preventDefault) ⇒ the keystroke stays available to the // composer, matching what `clarifyCardOwnsKey` reports with no visible card. expect(fireEvent.keyDown(window, { key: 'Enter' })).toBe(true) - expect(request).not.toHaveBeenCalled() + expect(background).not.toHaveBeenCalled() }) /** A card in its own split zone — unlike `backgroundCard` this one IS on @@ -998,11 +1004,9 @@ describe('ClarifyTool visible-card scoping', () => { /** Both zones visible, zone-a first in document order. */ function renderSplit() { - const request = vi.fn().mockResolvedValue({ ok: true }) - - $gateway.set({ request } as never) - parkClarify(ZONE_A_REQUEST, ZONE_A_SESSION) - parkClarify(ZONE_B_REQUEST, ZONE_B_SESSION) + $gateway.set({ request: vi.fn() } as never) + const zoneA = parkClarify(ZONE_A_REQUEST, ZONE_A_SESSION) + const zoneB = parkClarify(ZONE_B_REQUEST, ZONE_B_SESSION) renderClarify( <> @@ -1011,42 +1015,38 @@ describe('ClarifyTool visible-card scoping', () => { </> ) - return request + return { zoneA, zoneB } } it('answers the later-in-document card when its zone is the focused one', async () => { - const request = renderSplit() + const { zoneA, zoneB } = renderSplit() $activeTreeGroup.set('zone-b') fireEvent.keyDown(window, { key: 'Enter' }) await waitFor(() => { - expect(request).toHaveBeenCalledTimes(1) + expect(zoneB).toHaveBeenCalledTimes(1) }) // Both cards are visible and both hold a live window listener, so this is // the case document order gets wrong: it would answer zone-a's question. - expect(request).toHaveBeenCalledWith('clarify.respond', { - answer: 'staging', - request_id: ZONE_B_REQUEST - }) + expect(zoneB).toHaveBeenCalledWith({ answer: 'staging' }) + expect(zoneA).not.toHaveBeenCalled() }) it('answers the other visible card once the focus moves to its zone', async () => { - const request = renderSplit() + const { zoneA, zoneB } = renderSplit() $activeTreeGroup.set('zone-a') fireEvent.keyDown(window, { key: 'Enter' }) await waitFor(() => { - expect(request).toHaveBeenCalledTimes(1) + expect(zoneA).toHaveBeenCalledTimes(1) }) // The direct pin for "the other visible card then cannot receive its // shortcut": neither zone may be permanently starved of its own keys. - expect(request).toHaveBeenCalledWith('clarify.respond', { - answer: 'staging', - request_id: ZONE_A_REQUEST - }) + expect(zoneA).toHaveBeenCalledWith({ answer: 'staging' }) + expect(zoneB).not.toHaveBeenCalled() }) }) -
apps/desktop/src/components/assistant-ui/clarify-tool.tsx@@ -39,6 +39,7 @@ import { } from '@/store/clarify' import { $gateway } from '@/store/gateway' import { notifyError } from '@/store/notifications' +import { forgetServerRequest, respondToServerRequest } from '@/store/server-requests' import { requestForOwnedSession } from '@/store/session-states' import { handleClarifySubmitShortcut } from './clarify-submit-shortcut' @@ -476,22 +477,9 @@ function ClarifyToolSinglePending({ setSubmitting(true) try { - // Route through the session's OWNER (tile route → hint → tagged row); - // legacy ambient is allowed only when it is provably the sole backend. - // The ambient socket follows foreground focus, so after a profile / Bot - // Chat switch it can point at a backend that never held this clarify — - // and the owner stays blocked (#91684 client half, like approval.respond). - await requestForOwnedSession<{ ok?: boolean }>( - matchingRequest.sessionId, - // Bound (not wrapped) so the ambient fallback keeps the exact 2-arg - // call shape gateway.request callers assert on. - gateway.request.bind(gateway) as typeof gateway.request, - 'clarify.respond', - { - request_id: matchingRequest.requestId, - answer - } - ) + // The response frame goes back over the socket the request arrived on — + // the owner backend by construction (#91684's class cannot recur). + respondToServerRequest(matchingRequest.requestId, { answer }) triggerHaptic('submit') onAnswered() clearClarifyRequest(matchingRequest.requestId, matchingRequest.sessionId) @@ -1036,21 +1024,18 @@ function ClarifyToolBatchPending({ onAnswered, request }: { onAnswered: () => vo setSubmitting(true) try { - // Sequential, not Promise.all: the LAST lock resolves the blocked tool - // server-side, so every earlier lock must already be accepted when it - // lands — a reordered burst could complete the batch with a missing - // answer. - // - // Each lock rides the session's OWNER socket, not the ambient one: a - // profile / Bot Chat switch re-points ambient at a backend that never - // held this batch, which would leave the owner blocked. + // Sequential, not Promise.all: the LAST lock resolves the blocked + // server request, so every earlier lock must already be accepted when + // it lands — a reordered burst could complete the batch with a missing + // answer. `clarify.lock` is a normal RPC; it rides the session's OWNER + // socket (a profile / Bot Chat switch re-points ambient elsewhere). for (const question of questions) { const answer = stagedAnswer(question) - await requestForOwnedSession<{ ok?: boolean }>( + await requestForOwnedSession<{ remaining?: string[]; status?: string }>( request.sessionId, gateway.request.bind(gateway) as typeof gateway.request, - 'clarify.respond', + 'clarify.lock', { answer: answer ?? '', question_id: question.qid, @@ -1059,6 +1044,8 @@ function ClarifyToolBatchPending({ onAnswered, request }: { onAnswered: () => vo ) } + forgetServerRequest(request.requestId) + triggerHaptic('submit') onAnswered() // tool.complete lands next → ClarifyToolBatchSettled. @@ -1095,20 +1082,8 @@ function ClarifyToolBatchPending({ onAnswered, request }: { onAnswered: () => vo onAnswered() clearClarifyRequest(request.requestId, request.sessionId) - try { - if (gateway) { - // Owner-routed like the locks above — a skip sent to the wrong backend - // is a silent no-op that leaves the agent waiting out its timeout. - await requestForOwnedSession( - request.sessionId, - gateway.request.bind(gateway) as typeof gateway.request, - 'clarify.respond', - { answer: '', request_id: request.requestId } - ) - } - } catch { - // The tool times out on its own; a failed skip must never block the UI. - } + // A response with no `answers` is the cancel-all (the plain Esc path). + respondToServerRequest(request.requestId, {}) }, [gateway, onAnswered, request]) const handleSubmit = useCallback( -
apps/desktop/src/components/assistant-ui/mcp-setup-tool.tsx@@ -29,6 +29,7 @@ import { cn } from '@/lib/utils' import { $gateway } from '@/store/gateway' import { clearMcpSetupRequest, type McpSetupOutcome, sessionMcpSetupRequest } from '@/store/mcp-setup' import { notifyError } from '@/store/notifications' +import { respondToServerRequest } from '@/store/server-requests' import { invalidateMcpSuggestionIndex } from '@/store/suggestion-providers/mcp' import { selectMessageRunning } from './tool/fallback-model' @@ -229,15 +230,8 @@ function McpSetupPending({ args }: ToolCallMessagePartProps) { invalidateMcpSuggestionIndex() } - try { - await gateway.request<{ status?: string }>('mcp.setup.respond', { - request_id: request.requestId, - result: JSON.stringify(outcome) - }) - // tool.complete lands next → McpSetupSettled. - } catch (error) { - notifyError(error, copy.sendFailed) - } + respondToServerRequest(request.requestId, { value: JSON.stringify(outcome) }) + // tool.complete lands next → McpSetupSettled. }, [copy.gatewayDisconnected, copy.reloadFailed, copy.sendFailed, gateway, request] ) -
apps/desktop/src/components/assistant-ui/tool/approval.test.tsx@@ -1,9 +1,10 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' -import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { HermesGateway } from '@/hermes' import { $gateway } from '@/store/gateway' import { $approvalRequest, clearAllPrompts, setApprovalRequest } from '@/store/prompts' +import { hasOpenServerRequest, rememberServerRequest, resetServerRequestsForTests } from '@/store/server-requests' import { $activeSessionId } from '@/store/session' import { PendingApprovalFallback, PendingToolApproval } from './approval' @@ -33,12 +34,20 @@ function part(toolName: string): ToolPart { function setRequest( command = 'rm -rf /tmp/x', allowPermanent?: boolean, - extra: { choices?: string[]; smartDenied?: boolean } = {} + extra: { choices?: string[]; requestId?: string; serverRequestId?: string; smartDenied?: boolean } = {} ) { $activeSessionId.set('sess-1') setApprovalRequest({ allowPermanent, command, description: 'dangerous command', sessionId: 'sess-1', ...extra }) } +/** A live `approval` server request the card answers synchronously. */ +function liveApproval(id = 'srq-approval') { + const respond = vi.fn() + rememberServerRequest({ fail: vi.fn(), id, method: 'approval', params: {}, respond }) + + return respond +} + function mockGateway() { const request = vi.fn().mockResolvedValue({ resolved: true }) $gateway.set({ request } as unknown as HermesGateway) @@ -46,9 +55,14 @@ function mockGateway() { return request } +beforeEach(() => { + resetServerRequestsForTests() +}) + afterEach(() => { cleanup() clearAllPrompts() + resetServerRequestsForTests() $activeSessionId.set(null) $gateway.set(null) }) @@ -83,15 +97,37 @@ describe('PendingToolApproval', () => { expect(screen.getByRole('button', { name: /Reject/ })).toBeTruthy() }) - it('sends approval.respond {choice: "once"} and clears the request on Run', async () => { + it('answers the live approval request with {choice: "once"} and clears the request on Run', async () => { const request = mockGateway() - setRequest() + const respond = liveApproval() + setRequest('rm -rf /tmp/x', undefined, { requestId: 'apr-1', serverRequestId: 'srq-approval' }) + render(<PendingToolApproval part={part('terminal')} />) + + fireEvent.click(screen.getByRole('button', { name: /Run/ })) + + await waitFor(() => { + expect(respond).toHaveBeenCalledWith({ choice: 'once' }) + }) + expect(hasOpenServerRequest('srq-approval')).toBe(false) + expect(request).not.toHaveBeenCalledWith('approval.respond', expect.anything()) + expect($approvalRequest.get()).toBeNull() + }) + + it('falls back to the approval.respond RPC when no live server request is registered', async () => { + // A prompt restored from `approval.pending` (no socket carried the frame): + // the queue-level RPC is the only way to answer it. + const request = mockGateway() + setRequest('rm -rf /tmp/x', undefined, { requestId: 'apr-1' }) render(<PendingToolApproval part={part('terminal')} />) fireEvent.click(screen.getByRole('button', { name: /Run/ })) await waitFor(() => { - expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'once', session_id: 'sess-1' }) + expect(request).toHaveBeenCalledWith('approval.respond', { + choice: 'once', + request_id: 'apr-1', + session_id: 'sess-1' + }) }) expect($approvalRequest.get()).toBeNull() }) @@ -109,15 +145,16 @@ describe('PendingToolApproval', () => { expect(screen.getByText(longCommand)).toBeTruthy() }) - it('sends choice "deny" on Reject', async () => { - const request = mockGateway() - setRequest() + it('answers the live approval request with {choice: "deny"} on Reject', async () => { + mockGateway() + const respond = liveApproval() + setRequest('rm -rf /tmp/x', undefined, { requestId: 'apr-1', serverRequestId: 'srq-approval' }) render(<PendingToolApproval part={part('terminal')} />) fireEvent.click(screen.getByRole('button', { name: /Reject/ })) await waitFor(() => { - expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'deny', session_id: 'sess-1' }) + expect(respond).toHaveBeenCalledWith({ choice: 'deny' }) }) }) -
apps/desktop/src/components/assistant-ui/tool/approval.tsx@@ -21,6 +21,7 @@ import { isSubmitEnter } from '@/lib/ime' import { cn } from '@/lib/utils' import { $gateway } from '@/store/gateway' import { notifyError } from '@/store/notifications' +import { answerApproval } from '@/store/prompts' import { type ApprovalRequest, clearApprovalRequest, @@ -29,7 +30,6 @@ import { sessionApprovalInlineVisible, sessionApprovalRequest } from '@/store/prompts' -import { requestForOwnedSession } from '@/store/session-states' import type { ToolPart } from './fallback-model' @@ -145,22 +145,10 @@ const ApprovalBar: FC<{ request: ApprovalRequest; surface: 'floating' | 'inline' setSubmitting(choice) try { - // Route through the session's OWNER (tile route → known profile); - // ambient only when no owner is known. The ambient socket follows - // foreground focus, and for a cross-profile session it points at a - // backend that never held this approval (#91684 client half). - await requestForOwnedSession<{ resolved?: boolean }>( - request.sessionId, - // Bound (not wrapped) so the ambient fallback keeps the exact - // 2-arg call shape gateway.request callers assert on. - gateway.request.bind(gateway) as typeof gateway.request, - 'approval.respond', - { - choice, - request_id: request.requestId, - session_id: request.sessionId ?? undefined - } - ) + // Live prompt: the response frame rides the socket the request came on + // (the owner backend by construction). Restored prompt: queue-level + // `approval.respond`, owner-routed (#91684 client half). + await answerApproval(gateway, request, choice) triggerHaptic(choice === 'deny' ? 'cancel' : 'submit') clearApprovalRequest(request.sessionId, request.requestId) void replayPendingApproval(gateway, request.sessionId).catch(() => undefined) @@ -169,7 +157,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest; surface: 'floating' | 'inline' setSubmitting(null) } }, - [busy, copy.gatewayDisconnected, copy.sendFailed, gateway, request.requestId, request.sessionId] + [busy, copy.gatewayDisconnected, copy.sendFailed, gateway, request] ) // ⌘/Ctrl+Enter → Run, Esc → Reject. -
apps/desktop/src/components/prompt-overlays.test.tsx@@ -1,10 +1,11 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { I18nProvider } from '@/i18n' import { $gateway } from '@/store/gateway' import { notifyError } from '@/store/notifications' import { $secretRequest, $sudoRequest, clearAllPrompts, setSecretRequest, setSudoRequest } from '@/store/prompts' +import { hasOpenServerRequest, rememberServerRequest, resetServerRequestsForTests } from '@/store/server-requests' import { $activeSessionId } from '@/store/session' import { PromptOverlays } from './prompt-overlays' @@ -20,20 +21,48 @@ function renderPrompts(sessionId: string | null = 's1') { ) } +beforeEach(() => { + resetServerRequestsForTests() +}) + afterEach(() => { cleanup() clearAllPrompts() + resetServerRequestsForTests() $activeSessionId.set(null) $gateway.set(null) vi.clearAllMocks() }) describe('PromptOverlays', () => { - it('dismisses a stale sudo dialog when the gateway no longer has the password request', async () => { - const request = vi.fn().mockRejectedValue(new Error('no pending password request')) + it('answers the live sudo request with an empty value on Cancel and clears the dialog', async () => { + const respond = vi.fn() + const request = vi.fn() + + $activeSessionId.set('s1') + $gateway.set({ request } as never) + rememberServerRequest({ fail: vi.fn(), id: 'sudo-1', method: 'sudo', params: {}, respond }) + setSudoRequest({ requestId: 'sudo-1', sessionId: 's1' }) + + renderPrompts() + + expect(screen.getByText('Administrator password')).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + + await waitFor(() => expect($sudoRequest.get()).toBeNull()) + expect(respond).toHaveBeenCalledWith({ value: '' }) + expect(hasOpenServerRequest('sudo-1')).toBe(false) + expect(request).not.toHaveBeenCalled() + expect(notifyError).not.toHaveBeenCalled() + }) + + it('dismisses a stale sudo dialog when the gateway no longer has the request open', async () => { + const request = vi.fn() $activeSessionId.set('s1') $gateway.set({ request } as never) + // No server request registered under this id: it expired / was cancelled. setSudoRequest({ requestId: 'sudo-1', sessionId: 's1' }) renderPrompts() @@ -43,12 +72,34 @@ describe('PromptOverlays', () => { fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) await waitFor(() => expect($sudoRequest.get()).toBeNull()) - expect(request).toHaveBeenCalledWith('sudo.respond', { password: '', request_id: 'sudo-1' }) + expect(request).not.toHaveBeenCalled() + expect(notifyError).not.toHaveBeenCalled() + }) + + it('answers the live secret request with an empty value on Cancel and clears the dialog', async () => { + const respond = vi.fn() + const request = vi.fn() + + $activeSessionId.set('s1') + $gateway.set({ request } as never) + rememberServerRequest({ fail: vi.fn(), id: 'secret-1', method: 'secret', params: {}, respond }) + setSecretRequest({ envVar: 'TEST_SECRET', prompt: 'Paste a secret', requestId: 'secret-1', sessionId: 's1' }) + + renderPrompts() + + expect(screen.getByText('TEST_SECRET')).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + + await waitFor(() => expect($secretRequest.get()).toBeNull()) + expect(respond).toHaveBeenCalledWith({ value: '' }) + expect(hasOpenServerRequest('secret-1')).toBe(false) + expect(request).not.toHaveBeenCalled() expect(notifyError).not.toHaveBeenCalled() }) - it('dismisses a stale secret dialog when the gateway no longer has the value request', async () => { - const request = vi.fn().mockRejectedValue(new Error('no pending value request')) + it('dismisses a stale secret dialog when the gateway no longer has the request open', async () => { + const request = vi.fn() $activeSessionId.set('s1') $gateway.set({ request } as never) @@ -61,7 +112,7 @@ describe('PromptOverlays', () => { fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) await waitFor(() => expect($secretRequest.get()).toBeNull()) - expect(request).toHaveBeenCalledWith('secret.respond', { request_id: 'secret-1', value: '' }) + expect(request).not.toHaveBeenCalled() expect(notifyError).not.toHaveBeenCalled() }) }) -
apps/desktop/src/components/prompt-overlays.tsx@@ -33,8 +33,7 @@ import { sessionVaultSaveLoginRequest, sessionVaultUnlockRequest } from '@/store/prompts' -import { ambientRequestFor } from '@/store/session-gone-latch' -import { requestForOwnedSession } from '@/store/session-states' +import { respondToServerRequest } from '@/store/server-requests' // Renders the modal mid-turn prompts the gateway raises and waits on: sudo // password and skill secret capture. Dangerous-command / execute_code approval @@ -78,10 +77,7 @@ function SudoDialog({ sessionId }: { sessionId: string | null }) { setSubmitting(true) try { - await gateway.request<{ status?: string }>('sudo.respond', { - password: [redacted:secret-assignment] - request_id: request.requestId - }) + respondToServerRequest(request.requestId, { value }) triggerHaptic('submit') clearSudoRequest(request.sessionId, request.requestId) } catch (error) { @@ -181,10 +177,7 @@ function SecretDialog({ sessionId }: { sessionId: string | null }) { setSubmitting(true) try { - await gateway.request<{ status?: string }>('secret.respond', { - request_id: request.requestId, - value: secret - }) + respondToServerRequest(request.requestId, { value: secret }) triggerHaptic('submit') clearSecretRequest(request.sessionId, request.requestId) } catch (error) { @@ -285,14 +278,9 @@ function VaultUnlockDialog({ sessionId }: { sessionId: string | null }) { setSubmitting(true) try { - // A master password must reach the backend that raised the prompt, not whatever - // gateway is foreground right now (background profile tiles have their own socket). - await requestForOwnedSession<{ status?: string }>( - request.sessionId, - ambientRequestFor(gateway), - 'vault.unlock.respond', - { request_id: request.requestId, password } - ) + // The response frame goes back over the socket the request arrived on — the + // backend that raised the prompt, never whatever gateway is foreground. + respondToServerRequest(request.requestId, { value: password }) triggerHaptic('submit') clearVaultUnlockRequest(request.sessionId, request.requestId) } catch (error) { @@ -387,12 +375,7 @@ function VaultSaveLoginDialog({ sessionId }: { sessionId: string | null }) { setSubmitting(true) try { - await requestForOwnedSession<{ status?: string }>( - request.sessionId, - ambientRequestFor(gateway), - 'vault.save_login.respond', - { login, request_id: request.requestId } - ) + respondToServerRequest(request.requestId, { value: login }) triggerHaptic('submit') clearVaultSaveLoginRequest(request.sessionId, request.requestId) } catch (error) { @@ -504,12 +487,7 @@ function VaultCodeDialog({ sessionId }: { sessionId: string | null }) { setSubmitting(true) try { - await requestForOwnedSession<{ status?: string }>( - request.sessionId, - ambientRequestFor(gateway), - 'vault.code.respond', - { code: value, request_id: request.requestId } - ) + respondToServerRequest(request.requestId, { value }) triggerHaptic('submit') clearVaultCodeRequest(request.sessionId, request.requestId) } catch (error) { -
apps/desktop/src/components/prompt-overlays.vault-code.test.tsx@@ -1,16 +1,8 @@ import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' -import { afterEach, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { stubResizeObserver } from '@/test/jsdom' -const gatewayMocks = vi.hoisted(() => ({ - requestGatewayForAgent: vi.fn(async () => ({ status: 'ok' })) -})) - -vi.mock('@/store/gateway', async importActual => ({ - ...(await importActual<Record<string, unknown>>()), - requestGatewayForAgent: gatewayMocks.requestGatewayForAgent -})) vi.mock('@/lib/haptics', () => ({ triggerHaptic: vi.fn() })) vi.mock('@/store/notifications', () => ({ notify: vi.fn(), notifyError: vi.fn() })) @@ -18,26 +10,35 @@ import { PromptOverlays } from '@/components/prompt-overlays' import { $gateway } from '@/store/gateway' import { $profiles } from '@/store/profile' import { clearAllPrompts, sessionVaultCodeRequest, setVaultCodeRequest } from '@/store/prompts' +import { hasOpenServerRequest, rememberServerRequest, resetServerRequestsForTests } from '@/store/server-requests' import { $activeSessionId, _resetSessionOwnerHintsForTests, setSessionOwnerHint } from '@/store/session' stubResizeObserver() +beforeEach(() => { + resetServerRequestsForTests() +}) + afterEach(() => { cleanup() clearAllPrompts() + resetServerRequestsForTests() _resetSessionOwnerHintsForTests() $gateway.set(null) vi.clearAllMocks() }) -// The 2FA code goes to the OWNING profile socket as vault.code.respond, whitespace/dashes stripped -// (users paste "246 810" from an SMS); Skip answers "". -it('sends the trimmed code to the owning profile socket', async () => { +// The 2FA code answers the `vault.code` server request (the frame rides the socket the +// request arrived on, never the ambient gateway), whitespace/dashes stripped (users paste +// "246 810" from an SMS); Skip answers "". +it('answers the vault.code server request with the trimmed code, never the ambient gateway', async () => { $profiles.set([{ name: 'owner' }, { name: 'profile-b' }] as never) setSessionOwnerHint('session-a', { connectionId: 'conn-1', profile: 'owner' }) const ambient = vi.fn().mockResolvedValue({ status: 'ok' }) + const respond = vi.fn() $activeSessionId.set('session-b') $gateway.set({ request: ambient } as never) + rememberServerRequest({ fail: vi.fn(), id: 'req-c', method: 'vault.code', params: {}, respond }) setVaultCodeRequest({ hint: '', requestId: 'req-c', sessionId: 'session-a', site: 'github.com' }) render(<PromptOverlays sessionId="session-a" />) @@ -49,13 +50,9 @@ it('sends the trimmed code to the owning profile socket', async () => { expect(submit.disabled).toBe(false) fireEvent.submit(input.closest('form')!) - await waitFor(() => expect(gatewayMocks.requestGatewayForAgent).toHaveBeenCalledTimes(1)) - expect((gatewayMocks.requestGatewayForAgent.mock.calls[0] as unknown[]).slice(0, 4)).toEqual([ - 'conn-1', - 'owner', - 'vault.code.respond', - { code: '246810', request_id: 'req-c' } - ]) + await waitFor(() => expect(respond).toHaveBeenCalledTimes(1)) + expect(respond).toHaveBeenCalledWith({ value: '246810' }) + expect(hasOpenServerRequest('req-c')).toBe(false) expect(ambient).not.toHaveBeenCalled() await waitFor(() => expect(sessionVaultCodeRequest('session-a').get()).toBeNull()) }) @@ -63,13 +60,15 @@ it('sends the trimmed code to the owning profile socket', async () => { it('Skip answers an empty code and clears the card', async () => { $profiles.set([{ name: 'owner' }] as never) setSessionOwnerHint('session-a', { connectionId: 'conn-1', profile: 'owner' }) + const respond = vi.fn() $gateway.set({ request: vi.fn() } as never) + rememberServerRequest({ fail: vi.fn(), id: 'req-d', method: 'vault.code', params: {}, respond }) setVaultCodeRequest({ hint: '', requestId: 'req-d', sessionId: 'session-a', site: 'github.com' }) render(<PromptOverlays sessionId="session-a" />) fireEvent.click(Array.from(document.querySelectorAll('button')).find(b => b.textContent === 'Skip')!) - await waitFor(() => expect(gatewayMocks.requestGatewayForAgent).toHaveBeenCalledTimes(1)) - expect((gatewayMocks.requestGatewayForAgent.mock.calls[0] as unknown[])[3]).toEqual({ code: '', request_id: 'req-d' }) + await waitFor(() => expect(respond).toHaveBeenCalledTimes(1)) + expect(respond).toHaveBeenCalledWith({ value: '' }) await waitFor(() => expect(sessionVaultCodeRequest('session-a').get()).toBeNull()) }) -
apps/desktop/src/components/prompt-overlays.vault-save-login.test.tsx@@ -1,16 +1,8 @@ import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' -import { afterEach, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { stubResizeObserver } from '@/test/jsdom' -const gatewayMocks = vi.hoisted(() => ({ - requestGatewayForAgent: vi.fn(async () => ({ status: 'ok' })) -})) - -vi.mock('@/store/gateway', async importActual => ({ - ...(await importActual<Record<string, unknown>>()), - requestGatewayForAgent: gatewayMocks.requestGatewayForAgent -})) vi.mock('@/lib/haptics', () => ({ triggerHaptic: vi.fn() })) vi.mock('@/store/notifications', () => ({ notify: vi.fn(), notifyError: vi.fn() })) @@ -18,26 +10,36 @@ import { PromptOverlays } from '@/components/prompt-overlays' import { $gateway } from '@/store/gateway' import { $profiles } from '@/store/profile' import { clearAllPrompts, sessionVaultSaveLoginRequest, setVaultSaveLoginRequest } from '@/store/prompts' +import { hasOpenServerRequest, rememberServerRequest, resetServerRequestsForTests } from '@/store/server-requests' import { $activeSessionId, _resetSessionOwnerHintsForTests, setSessionOwnerHint } from '@/store/session' stubResizeObserver() +beforeEach(() => { + resetServerRequestsForTests() +}) + afterEach(() => { cleanup() clearAllPrompts() + resetServerRequestsForTests() _resetSessionOwnerHintsForTests() $gateway.set(null) vi.clearAllMocks() }) -// The "save this login" card is the zero-setup path: the pair goes to the OWNING profile's socket as -// one JSON answer, the password field is masked, and Save is disabled until both fields are filled. -it('sends identifier + password as one vault.save_login.respond to the owning profile socket', async () => { +// The "save this login" card is the zero-setup path: the pair answers the `vault.save_login` +// server request as one JSON value (the frame rides the socket the request arrived on, never +// the ambient gateway), the password field is masked, and Save is disabled until both fields +// are filled. +it('answers the vault.save_login server request with identifier + password as one JSON value', async () => { $profiles.set([{ name: 'owner' }, { name: 'profile-b' }] as never) setSessionOwnerHint('session-a', { connectionId: 'conn-1', profile: 'owner' }) const ambient = vi.fn().mockResolvedValue({ status: 'ok' }) + const respond = vi.fn() $activeSessionId.set('session-b') $gateway.set({ request: ambient } as never) + rememberServerRequest({ fail: vi.fn(), id: 'req-s', method: 'vault.save_login', params: {}, respond }) setVaultSaveLoginRequest({ origin: 'https://github.com', requestId: 'req-s', @@ -57,13 +59,13 @@ it('sends identifier + password as one vault.save_login.respond to the owning pr expect(submit.disabled).toBe(false) fireEvent.submit(password.closest('form')!) - await waitFor(() => expect(gatewayMocks.requestGatewayForAgent).toHaveBeenCalledTimes(1)) - const [conn, profile, method, params] = gatewayMocks.requestGatewayForAgent.mock.calls[0] as unknown[] - expect([conn, profile, method]).toEqual(['conn-1', 'owner', 'vault.save_login.respond']) - expect(JSON.parse((params as { login: string }).login)).toEqual({ + await waitFor(() => expect(respond).toHaveBeenCalledTimes(1)) + const [result] = respond.mock.calls[0] as [{ value: string }] + expect(JSON.parse(result.value)).toEqual({ identifier: '[redacted:email]', password: '[redacted:secret-assignment]' }) + expect(hasOpenServerRequest('req-s')).toBe(false) expect(ambient).not.toHaveBeenCalled() await waitFor(() => expect(sessionVaultSaveLoginRequest('session-a').get()).toBeNull()) }) @@ -71,7 +73,9 @@ it('sends identifier + password as one vault.save_login.respond to the owning pr it("Don't save answers an empty login and clears the card", async () => { $profiles.set([{ name: 'owner' }] as never) setSessionOwnerHint('session-a', { connectionId: 'conn-1', profile: 'owner' }) + const respond = vi.fn() $gateway.set({ request: vi.fn() } as never) + rememberServerRequest({ fail: vi.fn(), id: 'req-d', method: 'vault.save_login', params: {}, respond }) setVaultSaveLoginRequest({ origin: 'https://github.com', requestId: 'req-d', @@ -83,10 +87,7 @@ it("Don't save answers an empty login and clears the card", async () => { const decline = Array.from(document.querySelectorAll('button')).find(b => b.textContent === "Don't save")! fireEvent.click(decline) - await waitFor(() => expect(gatewayMocks.requestGatewayForAgent).toHaveBeenCalledTimes(1)) - expect((gatewayMocks.requestGatewayForAgent.mock.calls[0] as unknown[])[3]).toEqual({ - login: '', - request_id: 'req-d' - }) + await waitFor(() => expect(respond).toHaveBeenCalledTimes(1)) + expect(respond).toHaveBeenCalledWith({ value: '' }) await waitFor(() => expect(sessionVaultSaveLoginRequest('session-a').get()).toBeNull()) }) -
apps/desktop/src/components/prompt-overlays.vault-unlock.test.tsx@@ -1,16 +1,8 @@ import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' -import { afterEach, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { stubResizeObserver } from '@/test/jsdom' -const gatewayMocks = vi.hoisted(() => ({ - requestGatewayForAgent: vi.fn(async () => ({ status: 'ok' })) -})) - -vi.mock('@/store/gateway', async importActual => ({ - ...(await importActual<Record<string, unknown>>()), - requestGatewayForAgent: gatewayMocks.requestGatewayForAgent -})) vi.mock('@/lib/haptics', () => ({ triggerHaptic: vi.fn() })) vi.mock('@/store/notifications', () => ({ notify: vi.fn(), notifyError: vi.fn() })) @@ -18,13 +10,19 @@ import { PromptOverlays } from '@/components/prompt-overlays' import { $gateway } from '@/store/gateway' import { $profiles } from '@/store/profile' import { clearAllPrompts, sessionVaultUnlockRequest, setVaultUnlockRequest } from '@/store/prompts' +import { hasOpenServerRequest, rememberServerRequest, resetServerRequestsForTests } from '@/store/server-requests' import { $activeSessionId, _resetSessionOwnerHintsForTests, setSessionOwnerHint } from '@/store/session' stubResizeObserver() +beforeEach(() => { + resetServerRequestsForTests() +}) + afterEach(() => { cleanup() clearAllPrompts() + resetServerRequestsForTests() _resetSessionOwnerHintsForTests() $gateway.set(null) vi.clearAllMocks() @@ -33,12 +31,16 @@ afterEach(() => { // A master password typed into a background profile's unlock card must reach the // backend that raised the prompt. The window's ambient `$gateway` may be another // profile's socket entirely; sending the password there is a cross-backend leak. -it('routes the master password to the owning profile socket, never the ambient gateway', async () => { +// The answer is the `vault.unlock` server request's own response frame — it rides +// the socket the request arrived on, so the ambient gateway is never dialled. +it('answers the vault.unlock server request with the master password, never the ambient gateway', async () => { $profiles.set([{ name: 'owner' }, { name: 'profile-b' }] as never) setSessionOwnerHint('session-a', { connectionId: 'conn-1', profile: 'owner' }) const ambient = vi.fn().mockResolvedValue({ status: 'ok' }) + const respond = vi.fn() $activeSessionId.set('session-b') $gateway.set({ request: ambient } as never) + rememberServerRequest({ fail: vi.fn(), id: 'req-a', method: 'vault.unlock', params: {}, respond }) setVaultUnlockRequest({ backend: 'bitwarden', displayName: 'Bitwarden', requestId: 'req-a', sessionId: 'session-a' }) render(<PromptOverlays sessionId="session-a" />) @@ -46,12 +48,9 @@ it('routes the master password to the owning profile socket, never the ambient g fireEvent.change(input, { target: { value: 'fixture-master' } }) fireEvent.submit(input.closest('form')!) - await waitFor(() => expect(gatewayMocks.requestGatewayForAgent).toHaveBeenCalledTimes(1)) - expect(gatewayMocks.requestGatewayForAgent.mock.calls[0].slice(0, 3)).toEqual([ - 'conn-1', - 'owner', - 'vault.unlock.respond' - ]) + await waitFor(() => expect(respond).toHaveBeenCalledTimes(1)) + expect(respond).toHaveBeenCalledWith({ value: 'fixture-master' }) + expect(hasOpenServerRequest('req-a')).toBe(false) expect(ambient).not.toHaveBeenCalled() await waitFor(() => expect(sessionVaultUnlockRequest('session-a').get()).toBeNull()) }) -
apps/desktop/src/lib/gateway-events.test.ts@@ -34,7 +34,9 @@ describe('gateway event routing', () => { expect(gatewayEventRequiresSessionId('message.interim')).toBe(false) expect(gatewayEventRequiresSessionId('reasoning.delta')).toBe(false) expect(gatewayEventRequiresSessionId('tool.start')).toBe(false) - expect(gatewayEventRequiresSessionId('approval.request')).toBe(false) + // Prompts are server→client requests now; the one prompt-related EVENT + // left (`request.cancel`) is likewise the focused turn's own when unscoped. + expect(gatewayEventRequiresSessionId('request.cancel')).toBe(false) }) it('allows global events to remain unscoped', () => { -
apps/desktop/src/plugins/hermes-bots/group-test-utils.ts@@ -85,6 +85,7 @@ export interface GatewayOptions { /** Per profile: carry `pending_approval` on its first `until` resumes. */ approvalUntil?: Record<string, { payload: Record<string, unknown>; until: number }> /** Per profile: carry `pending_clarify` on its first `until` resumes. */ + /** `payload` is an open-request frame `{ id, method: 'clarify', params }`. */ clarifyUntil?: Record<string, { payload: Record<string, unknown>; until: number }> /** Land a competing writer's `ui_meta` under `key` during the FIRST * `profiles.configure`, then reject it as a CAS conflict — the race the @@ -275,7 +276,7 @@ export function createGroupGateway(options: GatewayOptions = {}): ScriptedGatewa running: false, session_id: session.runtime, session_key: session.stored, - ...(clarify && seen <= clarify.until ? { pending_clarify: clarify.payload } : {}), + ...(clarify && seen <= clarify.until ? { open_requests: [clarify.payload] } : {}), ...(approval && seen <= approval.until ? { pending_approval: approval.payload } : {}) } } -
apps/desktop/src/plugins/hermes-bots/group-turns.test.ts@@ -344,11 +344,12 @@ describe('reply selection (#94376)', () => { // command approval. Those live in a hidden session the room can't see, so the // poll mirrors them into the room and holds the turn open until they resolve. describe('clarify and approvals (#90694)', () => { + /** The member's blocking clarify as `session.resume` now reports it: an + * open server→client request frame (`open_requests`). */ const CLARIFY = { - choices: ['staging', 'prod'], - multi_select: false, - question: 'Which env should I target?', - request_id: 'req-clarify-1' + id: 'req-clarify-1', + method: 'clarify', + params: { choices: ['staging', 'prod'], multi_select: false, question: 'Which env should I target?' } } const APPROVAL = { @@ -365,7 +366,7 @@ describe('clarify and approvals (#90694)', () => { const room = await loadRoom({ clarifyUntil: { research: { payload: CLARIFY, until: 3 } }, // The mirror pass runs while the question is still blocking — this is - // the observable proof the gate inspected pending_clarify. Asserting + // the observable proof the gate inspected open_requests. Asserting // on $groupNeedsYou/$groupClarify AFTER the turn lands proves nothing: // the clarify has already resolved and its mirror is gone by then. onResumePoll: () => { @@ -401,7 +402,7 @@ describe('clarify and approvals (#90694)', () => { const { chat, turns } = await loadRoom() const member: GroupMember = { name: 'research', title: '' } - expect(turns.syncGroupClarify('Core', member, { pending_clarify: CLARIFY })).toBe(true) + expect(turns.syncGroupClarify('Core', member, { open_requests: [CLARIFY] })).toBe(true) const mirrored = Object.values(chat.$groupClarify.get()) @@ -414,7 +415,7 @@ describe('clarify and approvals (#90694)', () => { expect(turns.groupHasPendingClarify(chat.$groupClarify.get(), 'Core')).toBe(true) // Same request again: no new entry, identity preserved. - turns.syncGroupClarify('Core', member, { pending_clarify: CLARIFY }) + turns.syncGroupClarify('Core', member, { open_requests: [CLARIFY] }) expect(Object.values(chat.$groupClarify.get())[0]).toBe(mirrored[0]) @@ -425,45 +426,50 @@ describe('clarify and approvals (#90694)', () => { expect(turns.groupHasPendingClarify(chat.$groupClarify.get(), 'Core')).toBe(false) }) - it('never mirrors a question for older backends without pending_clarify', async () => { + it('never mirrors a question for older backends without open_requests', async () => { const { chat, turns } = await loadRoom() expect(turns.syncGroupClarify('Core', { name: 'research' }, { messages: [] })).toBe(false) expect(Object.keys(chat.$groupClarify.get())).toHaveLength(0) }) - it('routes an answer through clarify.respond and clears the mirror', async () => { + it('answers the open request by id through request.answer and clears the mirror', async () => { const room = await loadRoom() const member: GroupMember = { name: 'research', title: '' } - room.turns.syncGroupClarify('Core', member, { pending_clarify: CLARIFY }) + room.turns.syncGroupClarify('Core', member, { open_requests: [CLARIFY] }) await room.turns.answerGroupClarify(Object.values(room.chat.$groupClarify.get())[0], member, 'staging') - expect(room.gateway.rpcFor('clarify.respond').map(call => call.params)).toEqual([ - { answer: 'staging', request_id: 'req-clarify-1' } + expect(room.gateway.rpcFor('request.answer').map(call => call.params)).toEqual([ + { id: 'req-clarify-1', result: { answer: 'staging' } } ]) expect(Object.keys(room.chat.$groupClarify.get())).toHaveLength(0) }) - it('sends one respond per batch question, in order', async () => { + it('locks one batch question per clarify.lock call, in order', async () => { const room = await loadRoom() const member: GroupMember = { name: 'research', title: '' } room.turns.syncGroupClarify('Core', member, { - pending_clarify: { - questions: [ - { choices: ['staging', 'prod'], qid: 'q0', question: 'Env?' }, - { choices: [], qid: 'q1', question: 'Region?' } - ], - request_id: 'req-batch-1' - } + open_requests: [ + { + id: 'req-batch-1', + method: 'clarify', + params: { + questions: [ + { choices: ['staging', 'prod'], qid: 'q0', question: 'Env?' }, + { choices: [], qid: 'q1', question: 'Region?' } + ] + } + } + ] }) await room.turns.answerGroupClarify(Object.values(room.chat.$groupClarify.get())[0], member, { q0: 'staging', q1: 'eu-west' }) - expect(room.gateway.rpcFor('clarify.respond').map(call => call.params)).toEqual([ + expect(room.gateway.rpcFor('clarify.lock').map(call => call.params)).toEqual([ { answer: 'staging', question_id: 'q0', request_id: 'req-batch-1' }, { answer: 'eu-west', question_id: 'q1', request_id: 'req-batch-1' } ]) @@ -473,8 +479,8 @@ describe('clarify and approvals (#90694)', () => { it('clears only the disbanded room’s mirrored questions', async () => { const room = await loadRoom() - room.turns.syncGroupClarify('Core', { name: 'research' }, { pending_clarify: CLARIFY }) - room.turns.syncGroupClarify('Other', { name: 'ops' }, { pending_clarify: { ...CLARIFY, request_id: 'req-2' } }) + room.turns.syncGroupClarify('Core', { name: 'research' }, { open_requests: [CLARIFY] }) + room.turns.syncGroupClarify('Other', { name: 'ops' }, { open_requests: [{ ...CLARIFY, id: 'req-2' }] }) room.turns.clearGroupClarify('Core') const remaining = Object.values(room.chat.$groupClarify.get()) @@ -489,7 +495,7 @@ describe('clarify and approvals (#90694)', () => { it('keeps pending prompts independent from mention attention through their lifecycle', async () => { const { chat, turns } = await loadRoom() const member = { name: 'research', title: '' } - turns.syncGroupClarify('Core', member, { pending_clarify: CLARIFY }) + turns.syncGroupClarify('Core', member, { open_requests: [CLARIFY] }) expect(chat.$groupNeedsYou.get().Core).toBeFalsy() turns.syncGroupClarify('Core', { name: 'ops' }, { pending_approval: APPROVAL }) expect(Object.values(chat.$groupClarify.get())).toHaveLength(2) @@ -540,7 +546,7 @@ describe('clarify and approvals (#90694)', () => { submitted = true } - if (method === 'clarify.respond') { + if (method === 'request.answer') { answered = true } @@ -550,7 +556,7 @@ describe('clarify and approvals (#90694)', () => { await held } - return { ...result, pending_clarify: CLARIFY } + return { ...result, open_requests: [CLARIFY] } } return result @@ -725,7 +731,7 @@ describe('clarify and approvals (#90694)', () => { if (recreate) { room.chat.updateGroupChat('Core', current => ({ ...current, roomId: 'replacement-room' })) - room.turns.syncGroupClarify('Core', member, { pending_clarify: CLARIFY }) + room.turns.syncGroupClarify('Core', member, { open_requests: [CLARIFY] }) } const roomsBefore = structuredClone(room.chat.$groupChats.get()) @@ -789,7 +795,7 @@ describe('clarify and approvals (#90694)', () => { } } - return { running: true, pending_clarify: CLARIFY } + return { running: true, open_requests: [CLARIFY] } } await room.rounds.runGroupChatRounds('Core', members, 'thread') @@ -889,14 +895,14 @@ describe('clarify and approvals (#90694)', () => { expect(room.gateway.rpcFor('approval.respond').map(call => call.params)).toEqual([ { choice: 'once', request_id: 'req-approval-1', session_id: 'rt-research-1' } ]) - expect(room.gateway.rpcFor('clarify.respond')).toHaveLength(0) + expect(room.gateway.rpcFor('request.answer')).toHaveLength(0) expect(Object.keys(room.chat.$groupClarify.get())).toHaveLength(0) }) it('lets clarify outrank approval when a snapshot carries both', async () => { const { chat, turns } = await loadRoom() - turns.syncGroupClarify('Core', { name: 'research' }, { pending_approval: APPROVAL, pending_clarify: CLARIFY }) + turns.syncGroupClarify('Core', { name: 'research' }, { open_requests: [CLARIFY], pending_approval: APPROVAL }) const entry = Object.values(chat.$groupClarify.get())[0] -
apps/desktop/src/plugins/hermes-bots/group-turns.ts@@ -101,8 +101,10 @@ interface GroupSessionSnapshot { inflight?: boolean message_count?: number messages?: GroupTurnTranscriptMessage[] + /** Still-open server→client requests (`server_requests.open_requests`); the + * member's blocking clarify question lives here as `{ id, method: 'clarify', params }`. */ + open_requests?: { id: string; method: string; params: Record<string, unknown> }[] pending_approval?: GroupPendingApproval - pending_clarify?: GroupPendingClarify running?: boolean session_id?: string session_key?: string @@ -437,13 +439,18 @@ const GROUP_TURN_HARD_CAP_MS = 20 * 60000 * `${group}::${memberKey}` (#90694). Returns true while a prompt is * blocking, so the turn poll can extend its deadline — a waiting prompt * must not be eaten by the group-turn timeout. Feature-detected: older - * backends without `pending_clarify`/`pending_approval` in the resume + * backends without `open_requests`/`pending_approval` in the resume * payload always sync to "no prompt". Clarify wins when both are somehow * present (approvals resolve inside tool batches; clarify is the outer * blocker). */ export function syncGroupClarify(group: string, member: GroupMember, state: GroupSessionSnapshot | null): boolean { const key = `${group}::${groupMemberKey(member)}` - const clarify = state && typeof state.pending_clarify === 'object' ? state.pending_clarify : null + const openClarify = Array.isArray(state?.open_requests) + ? state.open_requests.find(entry => entry?.method === 'clarify' && typeof entry.id === 'string' && entry.id) + : null + const clarify: GroupPendingClarify | null = openClarify + ? { ...(openClarify.params as GroupPendingClarify), request_id: openClarify.id } + : null // The `!requestId` bail below is what makes the approval branch reachable, // so an approval read there is never the null arm of this ternary — a fact @@ -586,15 +593,16 @@ export function renameGroupClarify(oldName: string, newName: string) { } } -/** Answer a member's pending prompt from the room. Routes to the member's - * OWN source (requestForBot), so cross-connection members work. - * - clarify: `clarify.respond`; batch questions send one respond per - * question, sequentially — the LAST lock resolves the blocked tool - * server-side (same contract as the 1:1 batch card). allow_expired - * server-side makes racing the timeout harmless. +/** Answer a member's pending prompt from the room. The prompt was mirrored + * from the member's resume snapshot (`open_requests` / `pending_approval`), so + * this window never held the live server request: answer through RPCs routed + * to the member's OWN source (requestForBot), so cross-connection members work. + * - clarify: `clarify.lock` per question, sequentially — the LAST lock + * resolves the blocked server request (same contract as the 1:1 batch + * card). A single question answers the open request by id through + * `request.answer` (the cross-socket proxy for a response frame). * - approval: `approval.respond` with the choice (once/session/always/deny), - * keyed by session + request_id — the same wire the 1:1 approval card - * and native notifications use. */ + * keyed by session + request_id — the queue-level wire every surface shares. */ export async function answerGroupClarify( entry: GroupPrompt, member: GroupMember, @@ -618,16 +626,16 @@ export async function answerGroupClarify( // Question ids are opaque on the wire (`GroupPrompt.questions` types // them `unknown`); the batch card keys its answer bag by exactly them. const qid = (question?.qid ?? question?.id) as string - await requestForBot(member, 'clarify.respond', { + await requestForBot(member, 'clarify.lock', { request_id: entry.requestId, question_id: qid, answer: (answers as Record<string, string>)?.[qid] ?? '' }) } } else { - await requestForBot(member, 'clarify.respond', { - request_id: entry.requestId, - answer: typeof answers === 'string' ? answers : '' + await requestForBot(member, 'request.answer', { + id: entry.requestId, + result: { answer: typeof answers === 'string' ? answers : '' } }) } -
apps/desktop/src/store/clarify.test.ts@@ -12,6 +12,7 @@ import { skipClarifyRequest } from './clarify' import { $gateway } from './gateway' +import { rememberServerRequest, resetServerRequestsForTests } from './server-requests' import { $activeSessionId } from './session' function clarify(sessionId: string | null, requestId: string): ClarifyRequest { @@ -91,6 +92,7 @@ describe('skipClarifyRequest', () => { beforeEach(() => { $clarifyRequests.set({}) + resetServerRequestsForTests() request.mockClear() $gateway.set({ request } as unknown as ReturnType<typeof $gateway.get>) }) @@ -101,12 +103,15 @@ describe('skipClarifyRequest', () => { }) it('answers the session\u2019s clarify with an empty answer and drops it', async () => { + const respond = vi.fn() + + rememberServerRequest({ fail: vi.fn(), id: 'req-a', method: 'clarify', params: {}, respond }) setClarifyRequest(clarify('session-a', 'req-a')) setClarifyRequest(clarify('session-b', 'req-b')) await expect(skipClarifyRequest('session-a')).resolves.toBe(true) - expect(request).toHaveBeenCalledWith('clarify.respond', { request_id: 'req-a', answer: '' }) + expect(respond).toHaveBeenCalledWith({ answer: '' }) expect(hasClarifyRequest('session-a')).toBe(false) // A background session's question is untouched — only the one being typed // over is skipped. @@ -118,9 +123,8 @@ describe('skipClarifyRequest', () => { expect(request).not.toHaveBeenCalled() }) - it('still reports the skip when the respond RPC fails', async () => { + it('still reports the skip when the server request is already gone (expired / other window answered)', async () => { setClarifyRequest(clarify('session-a', 'req-a')) - request.mockRejectedValueOnce(new Error('socket closed')) await expect(skipClarifyRequest('session-a')).resolves.toBe(true) expect(hasClarifyRequest('session-a')).toBe(false) -
apps/desktop/src/store/clarify.ts@@ -1,6 +1,6 @@ import { atom, computed } from 'nanostores' -import { $gateway } from './gateway' +import { respondToServerRequest } from './server-requests' import { $activeSessionId } from './session' export interface ClarifyQuestion { @@ -187,8 +187,8 @@ export const hasClarifyRequest = (sessionId: string | null | undefined): boolean * (default 5 min) — the message looks sent and nothing happens. Skipping lets * the tool return and the turn carry on with the user's actual words. * - * An empty answer is the same thing the card's own Skip button sends, and - * `clarify.respond` is `allow_expired`, so racing the timeout is harmless. + * An empty answer is the same thing the card's own Skip button sends; answering + * a request that already expired is a no-op, so racing the timeout is harmless. */ export async function skipClarifyRequest(sessionId: string | null | undefined): Promise<boolean> { const request = $clarifyRequests.get()[keyFor(sessionId)] @@ -201,12 +201,7 @@ export async function skipClarifyRequest(sessionId: string | null | undefined): // leave a live card the user can answer a second time. clearClarifyRequest(request.requestId, request.sessionId) - try { - await $gateway.get()?.request('clarify.respond', { request_id: request.requestId, answer: '' }) - } catch { - // The tool times out on its own; a failed skip must never swallow the - // message the user is actually sending. - } + respondToServerRequest(request.requestId, { answer: '' }) return true } -
apps/desktop/src/store/gateway-profile-only-owner.integration.test.ts@@ -129,7 +129,7 @@ afterEach(() => { }) describe('profile-only secondary approval ownership', () => { - it('keeps a profile-only secondary event owner across a focus switch and dispatches approval on that secondary', async () => { + it('keeps a profile-only secondary event owner across a focus switch and dispatches the approval.respond fallback on that secondary', async () => { await expect(ensureGatewayForProfile('research')).resolves.toBeUndefined() const secondary = gatewayMocks.instances[0] @@ -138,7 +138,10 @@ describe('profile-only secondary approval ownership', () => { expect($sessionTiles.get()).toEqual([]) expect($sessionStates.get()).toEqual({}) - secondary.emit({ session_id: 'rt-secondary', type: 'approval.request' }) + // Prompts now arrive as server→client request frames, not events; owner + // scope is still learned from any session-stamped event the secondary + // socket emits (here the turn's status update). + secondary.emit({ session_id: 'rt-secondary', type: 'status.update' }) await expect(ensureGatewayForProfile('default')).resolves.toBeUndefined() expect(knownOwnerForSession('rt-secondary')).toBe('research') @@ -170,8 +173,8 @@ describe('profile-only secondary approval ownership', () => { await ensureGatewayForProfile('research') const secondary = gatewayMocks.instances[0] - secondary.emit({ session_id: 'rt-retired', type: 'approval.request' }) - secondary.emit({ session_id: 'rt-durable', type: 'approval.request' }) + secondary.emit({ session_id: 'rt-retired', type: 'status.update' }) + secondary.emit({ session_id: 'rt-durable', type: 'status.update' }) expect(knownOwnerForSession('rt-retired')).toBe('research') const exact = { connectionId: 'remote-same-name', profile: 'research' } setSessionOwnerHint('rt-durable', exact) -
apps/desktop/src/store/gateway.ts@@ -3,7 +3,8 @@ import { type GatewayEvent, reconnectBackoffDelayMs, registryBackendScopeKey, - resolveGatewayWsUrl + resolveGatewayWsUrl, + type ServerRequest } from '@hermes/shared' import { atom } from 'nanostores' @@ -56,6 +57,10 @@ interface RegistryConfig { * the connection store. */ activeConnectionId?: () => null | string onEvent: (event: GatewayEvent) => void + /** Server→client request (clarify, approval, …) from ANY socket the registry owns; the + * request's `respond` already routes to the socket it came from. `profile` / + * `connectionId` tag the source the same way events are tagged. */ + onServerRequest?: (request: ScopedServerRequest) => void onActiveConnectionInvalidated?: (fallbackProfile: string, activationEpoch: number) => void onActiveConnectionChanged?: (connection: HermesConnection) => void /** @@ -100,6 +105,7 @@ interface Secondary { activeRequests: number connectPromise: Promise<void> | null offEvent: () => void + offRequest: () => void offState: () => void reconnectTimer: ReturnType<typeof setTimeout> | null reconnectAttempt: number @@ -269,6 +275,19 @@ export function emitLocalGatewayEvent(event: GatewayEvent): void { g.config?.onEvent(event) } +/** A server→client request tagged with the registry source it arrived from (like `GatewayEvent.profile`). */ +export interface ScopedServerRequest extends ServerRequest { + connectionId?: string + profile: string +} + +/** Fan a primary-socket server request into the registry handler with the active source tags. */ +export function dispatchPrimaryServerRequest(request: ServerRequest, profile: string): void { + const connectionId = g.config?.activeConnectionId?.() ?? null + + g.config?.onServerRequest?.({ ...request, ...(connectionId ? { connectionId } : {}), profile }) +} + export function setPrimaryGateway(gateway: HermesGateway | null, profile = 'default'): void { const next = normKey(profile) @@ -800,6 +819,7 @@ function createSecondary(profile: string, connectionId: null | string = null): S activeRequests: 0, connectPromise: null, offEvent: () => {}, + offRequest: () => {}, offState: () => {}, reconnectTimer: null, reconnectAttempt: 0, @@ -822,6 +842,10 @@ function createSecondary(profile: string, connectionId: null | string = null): S g.config?.onEvent(scopedEvent) releaseTerminalTurnLease(entry.scope, event) }) + entry.offRequest = + gateway.onRequest?.(request => { + g.config?.onServerRequest?.({ ...request, ...(connectionId ? { connectionId } : {}), profile }) + }) ?? (() => {}) entry.offState = gateway.onState(state => { reportGatewayState(scope, state) @@ -1741,6 +1765,7 @@ function disposeSecondary(entry: Secondary): void { entry.wantOpen = false clearTimer(entry) entry.offEvent() + entry.offRequest() entry.offState() entry.gateway.close() } -
apps/desktop/src/store/mcp-setup.ts@@ -1,6 +1,6 @@ import { atom, computed } from 'nanostores' -import { $gateway } from './gateway' +import { respondToServerRequest } from './server-requests' /** * Pending `mcp.setup.request`s — the desktop half of the `setup_mcp` tool's @@ -19,7 +19,7 @@ export interface McpSetupRequest { sessionId: string | null } -/** The card's answer, serialized back through `mcp.setup.respond`. */ +/** The card's answer, serialized back as the `mcp.setup` request's `{value}`. */ export interface McpSetupOutcome { status: 'authorized' | 'declined' | 'enabled' | 'error' | 'installed' server: string @@ -104,15 +104,9 @@ export async function skipMcpSetupRequest(sessionId: string | null | undefined): // leave a live card the user can answer a second time. clearMcpSetupRequest(request.requestId, request.sessionId) - try { - await $gateway.get()?.request('mcp.setup.respond', { - request_id: request.requestId, - result: JSON.stringify({ server: request.server, status: 'declined' }) - }) - } catch { - // The tool times out on its own; a failed skip must never swallow the - // message the user is actually sending. - } + respondToServerRequest(request.requestId, { + value: JSON.stringify({ server: request.server, status: 'declined' }) + }) return true } -
apps/desktop/src/store/native-notifications.ts@@ -5,10 +5,11 @@ import { persistString, storedString } from '@/lib/storage' import { $gateway } from './gateway' import { withinNativeNotifyBaseline } from './notify-baseline' +import { $approvalRequests, answerApproval } from './prompts' import { clearApprovalRequest } from './prompts' import { isSessionGone, isSessionGoneForBackgroundPolling, markSessionGone } from './runtime-gone' import { $activeSessionId } from './session' -import { requestForOwnedSession, storedSessionIdForRuntimeId } from './session-states' +import { storedSessionIdForRuntimeId } from './session-states' export type { HermesOpenTarget } @@ -366,18 +367,11 @@ export async function respondToApprovalAction(sessionId: null | string, actionId } try { - // Route through the session's OWNER (tile route → known profile); the - // ambient socket follows foreground focus and, for a background approval - // raised by a cross-profile session, points at a backend that never held - // the approval (#91684 client half). Ambient only when no owner is known. - await requestForOwnedSession( - sessionId, - // Bound (not wrapped) so the ambient fallback keeps the exact 2-arg - // call shape gateway.request callers assert on. - gateway.request.bind(gateway) as typeof gateway.request, - 'approval.respond', - { choice, session_id: sessionId ?? undefined } - ) + // The parked prompt knows how to answer itself: the live server request when + // still open, else the owner-routed queue-level RPC (#91684 client half). + const parked = $approvalRequests.get()[sessionId ?? ''] + + await answerApproval(gateway, parked ?? { sessionId: sessionId ?? null }, choice) clearApprovalRequest(sessionId) } catch (error) { if (sessionId && isSessionGoneForBackgroundPolling(error)) { -
apps/desktop/src/store/prompts.ts@@ -2,14 +2,16 @@ import { atom, computed, type ReadableAtom } from 'nanostores' import { $clarifyRequest, $clarifyRequests } from './clarify' import { isSessionGone, isSessionGoneForBackgroundPolling, markSessionGone } from './runtime-gone' +import { respondToServerRequest } from './server-requests' import { $activeSessionId } from './session' import { ambientRequestFor } from './session-gone-latch' import { requestForOwnedSession } from './session-states' -// Blocking interactive prompts the gateway raises mid-turn. Each maps to a -// `*.request` event the Python side emits while it blocks the agent thread -// waiting for a `*.respond` RPC. Without a renderer for these, the agent -// silently stalls until its timeout (default 5 min) and the tool is BLOCKED. +// Blocking interactive prompts the gateway raises mid-turn. Each is a +// server→client JSON-RPC request (`tui_gateway/server_requests.py`) the Python +// side blocks on; `requestId` is that request's id and the card answers through +// `store/server-requests.ts::respondToServerRequest`. Without a renderer for +// these the channel answers -32601 and the tool fails fast. // // Like clarify, every prompt is parked under the runtime session id that raised // it (not one shared slot), so a *background* session running concurrently can @@ -70,9 +72,12 @@ function keyedPromptStore<T extends KeyedPrompt>(): PromptStore<T> { } } -// Approval is session-keyed on the backend and correlated by `request_id` when -// available (legacy ID-free responses remain FIFO-compatible). Resolved via -// approval.respond {choice, request_id, session_id}. +// Approval is queue-backed on the backend (`tools/approval.py`): `requestId` is +// the QUEUE entry's id (what `approval.pending` / `approval.received` / +// `approval.respond` key on), stable across delivery paths. The live prompt +// arrives as an `approval` server request whose id is `serverRequestId`; the +// card answers that request when it is still open and falls back to the +// `approval.respond` RPC when the prompt was restored from `approval.pending`. export interface ApprovalRequest extends KeyedPrompt { // false when the backend won't honor a permanent allow (tirith warning) → hide "Always allow". allowPermanent?: boolean @@ -80,6 +85,7 @@ export interface ApprovalRequest extends KeyedPrompt { command: string description: string requestId?: string + serverRequestId?: string smartDenied?: boolean } @@ -106,8 +112,8 @@ export interface SecretRequest extends KeyedPrompt { requestId: string } -// External password-manager unlock (agent/vault_backends). Resolved via -// vault.unlock.respond {request_id, password}; "" keeps the manager locked. +// External password-manager unlock (agent/vault_backends): `vault.unlock_prompt` +// server request, answered `{value: password}`; "" keeps the manager locked. export interface VaultUnlockRequest extends KeyedPrompt { backend: string displayName: string @@ -119,8 +125,8 @@ const sudo = keyedPromptStore<SudoRequest>() const secret = [redacted:secret-assignment] const vaultUnlock = keyedPromptStore<VaultUnlockRequest>() -// "Save this login" for the page the agent is on (tools/browser_vault_tool). Resolved via -// vault.save_login.respond {request_id, login: JSON {identifier, password}}; "" declines. +// "Save this login" for the page the agent is on (tools/browser_vault_tool): `vault.save_login` +// server request, answered `{value: JSON {identifier, password}}`; "" declines. export interface VaultSaveLoginRequest extends KeyedPrompt { origin: string site: string @@ -129,8 +135,8 @@ export interface VaultSaveLoginRequest extends KeyedPrompt { const vaultSave = keyedPromptStore<VaultSaveLoginRequest>() -// Second-factor code for the page the agent is on. Resolved via vault.code.respond -// {request_id, code}; "" skips. +// Second-factor code for the page the agent is on: `vault.code` server request, +// answered `{value: code}`; "" skips. export interface VaultCodeRequest extends KeyedPrompt { site: string hint: string @@ -144,10 +150,19 @@ const vaultCode = keyedPromptStore<VaultCodeRequest>() const $approvalInlineAnchors = atom<Record<string, number>>({}) export const $approvalRequest = approval.$active +export const $approvalRequests = approval.$all export const setApprovalRequest = approval.set export const clearApprovalRequest = approval.clear export async function receiveApprovalRequest(gateway: ApprovalGateway | null, request: ApprovalRequest): Promise<void> { + // A prompt restored from `approval.pending` must not clobber the live server + // request that already carries the same queue entry (it knows how to answer). + const current = approval.$all.get()[keyFor(request.sessionId)] + + if (current?.requestId && current.requestId === request.requestId && current.serverRequestId && !request.serverRequestId) { + return + } + setApprovalRequest(request) if (gateway && request.requestId && request.sessionId) { @@ -210,6 +225,10 @@ export async function replayPendingApproval(gateway: ApprovalGateway | null, ses return } + if (previous?.requestId === pending.request_id) { + return + } + await receiveApprovalRequest(gateway, { allowPermanent: pending.allow_permanent !== false, choices: Array.isArray(pending.choices) ? pending.choices.filter(choice => typeof choice === 'string') : undefined, @@ -221,6 +240,35 @@ export async function replayPendingApproval(gateway: ApprovalGateway | null, ses }) } +/** + * Resolve an approval: answer the live server request when it is still open + * (the response frame goes back over the socket it arrived on — the owner by + * construction), else the queue-level `approval.respond` RPC for a prompt that + * was restored from `approval.pending` or is being answered from another + * surface. Returns after the backend has the decision. + */ +export async function answerApproval( + gateway: ApprovalGateway | null, + request: Pick<ApprovalRequest, 'requestId' | 'serverRequestId' | 'sessionId'>, + choice: string, + all = false +): Promise<void> { + if (respondToServerRequest(request.serverRequestId, { choice, ...(all ? { all: true } : {}) })) { + return + } + + if (!gateway) { + throw new Error('Hermes gateway is not connected') + } + + await requestForOwnedSession(request.sessionId, ambientRequestFor(gateway), 'approval.respond', { + ...(all ? { all: true } : {}), + choice, + ...(request.requestId ? { request_id: request.requestId } : {}), + session_id: request.sessionId ?? undefined + }) +} + /** The prompt request for one specific session — the tile counterpart of the * active-session `$*Request` views (same map, fixed key). */ export const sessionApprovalRequest = (sessionId: string | null) => @@ -250,10 +298,12 @@ export const sessionApprovalInlineVisible = (sessionId: string | null) => computed($approvalInlineAnchors, anchors => (anchors[keyFor(sessionId)] ?? 0) > 0) export const $sudoRequest = sudo.$active +export const $sudoRequests = sudo.$all export const setSudoRequest = sudo.set export const clearSudoRequest = sudo.clear export const $secretRequest = [redacted:secret-assignment] +export const $secretRequests = [redacted:secret-assignment] export const setSecretRequest = [redacted:secret-assignment] export const clearSecretRequest = [redacted:secret-assignment] -
apps/desktop/src/store/server-requests.ts@@ -0,0 +1,46 @@ +import type { ServerRequest } from '@hermes/shared' + +/** + * Live server→client requests (`tui_gateway/server_requests.py`) keyed by + * request id: clarify / approval / sudo / secret / vault / MCP-setup cards. + * + * The per-session prompt stores keep only the id; a card answers through + * `respondToServerRequest`, which routes the response frame back over the + * socket the request arrived on — the owner backend by construction, so no + * owner-route lookup is needed (#91684's whole class disappears: the answer + * cannot land on the wrong backend because it is a JSON-RPC response, not a + * new call). A request re-delivered after a reconnect (`open_requests`) + * carries the same id and replaces the entry, so the still-visible card + * answers the new generation. + */ +const open = new Map<string, ServerRequest>() + +export function rememberServerRequest(request: ServerRequest): void { + open.set(request.id, request) +} + +export function forgetServerRequest(id: string): void { + open.delete(id) +} + +/** Answer request `id`. False when nothing is open under that id (expired / already answered). */ +export function respondToServerRequest(id: string | undefined, result: Record<string, unknown>): boolean { + const request = id ? open.get(id) : undefined + + if (!request) { + return false + } + + open.delete(id!) + request.respond(result) + + return true +} + +export function hasOpenServerRequest(id: string): boolean { + return open.has(id) +} + +export function resetServerRequestsForTests(): void { + open.clear() +} -
apps/desktop/src/store/session-states-runtime-map.test.ts@@ -64,7 +64,7 @@ describe('storedSessionIdForRuntimeId', () => { }) it('maps a MAIN-PANE runtime id through the per-runtime state mirror (no tile involved)', () => { - // approval.respond from a native notification, a queued send: the caller + // The approval.respond RPC fallback from a native notification, a queued send: the caller // holds the runtime id of the primary thread, which no tile knows. The // state mirror carries the stored id the wiring cache bound. publishSessionState('rt-main', createClientSessionState('stored-main')) -
apps/desktop/src/types/hermes.ts@@ -673,17 +673,11 @@ export interface SessionResumeResponse { request_id?: string smart_denied?: boolean } - // The clarify question still blocking this session, if any. Same replay - // class as pending_approval: emitted-while-detached prompts are restored - // from the resume snapshot instead of being lost until server-side timeout. - pending_clarify?: { - answers?: Record<string, unknown> - choices?: null | string[] - multi_select?: boolean - question?: string - questions?: unknown - request_id?: string - } + // Server→client requests still unanswered for this session (clarify, sudo, + // vault prompts, …). The shared channel re-delivers them to the request + // handlers before this response resolves; listed here so resume can tell an + // authoritative "nothing pending" from a request the handler declined. + open_requests?: Array<{ id: string; method: string; params: Record<string, unknown> & { session_id?: string } }> info?: SessionRuntimeInfo message_count: number messages: SessionMessage[] -
apps/shared/src/gateway-events.json@@ -1,93 +1,86 @@ -[ - "agent.terminal.output", - "approval.request", - "background.complete", - "billing.step_up.verification", - "bot_relay.outbox.pending", - "browser.controller.cancel", - "browser.controller.command", - "browser.progress", - "btw.complete", - "clarify.expire", - "clarify.request", - "cron.changed", - "error", - "gateway.ready", - "layout.apply", - "mcp.setup.expire", - "mcp.setup.request", - "message.complete", - "message.delta", - "message.interim", - "message.reaction", - "message.start", - "moa.aggregating", - "moa.phase", - "moa.progress", - "moa.reference", - "notice", - "notification.clear", - "notification.show", - "pairing.changed", - "pane.reveal", - "pet.changed", - "pet.generate.progress", - "pet.hatch.progress", - "platforms.changed", - "preview.act.expire", - "preview.act.request", - "preview.close", - "preview.open", - "preview.read.expire", - "preview.read.request", - "preview.restart.complete", - "preview.restart.progress", - "reaction", - "reasoning.available", - "reasoning.delta", - "review.summary", - "secret.expire", - "secret.request", - "session.control.update", - "session.info", - "session.reclaimed", - "session.resume_progress", - "session.title", - "session.usage", - "sessions.changed", - "setup.ready", - "skin.changed", - "status.update", - "subagent.complete", - "subagent.progress", - "subagent.spawn_requested", - "subagent.start", - "subagent.thinking", - "subagent.tool", - "sudo.expire", - "sudo.request", - "terminal.close", - "terminal.read.expire", - "terminal.read.request", - "thinking.delta", - "tip.show", - "todo.updated", - "tool.complete", - "tool.generating", - "tool.output_risk", - "tool.start", - "tour.expire", - "tour.request", - "vault.code.expire", - "vault.code.request", - "vault.save_login.expire", - "vault.save_login.request", - "vault.unlock.expire", - "vault.unlock.request", - "voice.interrupted", - "voice.status", - "voice.transcript", - "wake.detected", - "window.read.expire", - "window.read.request" -] +{ + "events": [ + "agent.terminal.output", + "background.complete", + "billing.step_up.verification", + "bot_relay.outbox.pending", + "browser.controller.cancel", + "browser.controller.command", + "browser.progress", + "btw.complete", + "cron.changed", + "error", + "gateway.ready", + "layout.apply", + "message.complete", + "message.delta", + "message.interim", + "message.reaction", + "message.start", + "moa.aggregating", + "moa.phase", + "moa.progress", + "moa.reference", + "notice", + "notification.clear", + "notification.show", + "pairing.changed", + "pane.reveal", + "pet.changed", + "pet.generate.progress", + "pet.hatch.progress", + "platforms.changed", + "preview.close", + "preview.open", + "preview.restart.complete", + "preview.restart.progress", + "reaction", + "reasoning.available", + "reasoning.delta", + "request.cancel", + "review.summary", + "session.control.update", + "session.info", + "session.reclaimed", + "session.resume_progress", + "session.title", + "session.usage", + "sessions.changed", + "setup.ready", + "skin.changed", + "status.update", + "subagent.complete", + "subagent.progress", + "subagent.spawn_requested", + "subagent.start", + "subagent.thinking", + "subagent.tool", + "terminal.close", + "thinking.delta", + "tip.show", + "todo.updated", + "tool.complete", + "tool.generating", + "tool.output_risk", + "tool.start", + "voice.interrupted", + "voice.status", + "voice.transcript", + "wake.detected" + ], + "server_requests": [ + "approval", + "clarify", + "mcp.setup", + "preview.act", + "preview.read", + "secret", + "sudo", + "terminal.read", + "tour", + "vault.code", + "vault.save_login", + "vault.unlock_prompt", + "window.read" + ] +} -
apps/shared/src/gateway-events.test.ts@@ -1,22 +1,29 @@ import { describe, expect, it } from 'vitest' -import { BACKEND_EVENT_NAMES } from './gateway-events' +import { BACKEND_EVENT_NAMES, SERVER_REQUEST_METHODS } from './gateway-events' import contract from './gateway-events.json' /** * Two-sided contract with `tests/tui_gateway/test_gateway_event_contract.py`: the - * Python side pins the emitter call sites to `gateway-events.json`; this side pins - * `BACKEND_EVENT_NAMES` (which `BackendGatewayEventMap` is checked against via - * `satisfies`) to the same JSON. A name added on either side alone goes red here. + * Python side pins the emitter / server-request call sites to `gateway-events.json`; + * this side pins `BACKEND_EVENT_NAMES` (checked against `BackendGatewayEventMap` via + * `satisfies`) and `SERVER_REQUEST_METHODS` (against `ServerRequestMap`) to the same + * JSON. A name added on either side alone goes red here. */ -describe('gateway-events.json ⇄ BackendGatewayEventMap', () => { +describe('gateway-events.json ⇄ BackendGatewayEventMap / ServerRequestMap', () => { it('lists exactly the backend-emitted notification names', () => { - expect([...BACKEND_EVENT_NAMES]).toEqual(contract) + expect([...BACKEND_EVENT_NAMES]).toEqual(contract.events) }) - it('keeps both lists sorted and duplicate-free (stable diffs)', () => { - const sorted = [...contract].sort() - expect(contract).toEqual(sorted) - expect(new Set(contract).size).toBe(contract.length) + it('lists exactly the server→client request methods', () => { + expect([...SERVER_REQUEST_METHODS]).toEqual(contract.server_requests) + }) + + it.each([ + ['events', contract.events], + ['server_requests', contract.server_requests] + ])('keeps %s sorted and duplicate-free (stable diffs)', (_label, list) => { + expect(list).toEqual([...list].sort()) + expect(new Set(list).size).toBe(list.length) }) }) -
apps/shared/src/gateway-events.ts@@ -3,7 +3,8 @@ * TypeScript surfaces (Ink TUI, Desktop, web dashboard) share. * * Every notification arrives as `{jsonrpc: '2.0', method: 'event', params: GatewayEvent}` - * (`tui_gateway/server.py::_event_frame`). `GatewayEventMap` is the single map from + * (`tui_gateway/server.py::_event_frame`); server→client REQUESTS (`{id, method, params}`, + * `tui_gateway/server_requests.py`) are typed by `ServerRequestMap` below. `GatewayEventMap` is the single map from * event `type` to payload shape; `BACKEND_EVENT_NAMES` mirrors the emitter side and is * pinned to `gateway-events.json` by `gateway-events.test.ts` (vitest) and * `tests/tui_gateway/test_gateway_event_contract.py` (Python), so a name added on one @@ -304,11 +305,20 @@ export interface MoaPhasePayload { refs_total?: number } -// Blocking bridges (`tui_gateway/server.py::_block`): every `*.request` carries a -// `request_id`; the matching `*.expire` names the same id when the wait timed out. +// ── Server→client requests (`tui_gateway/server_requests.py`) ─────────── +// +// The backend asks the renderer a question with a real JSON-RPC request +// (`{id: 'srq-…', method, params}`) and blocks on the response frame. Every +// entry below is one method: its `params` shape and the `result` the client +// answers with. `request.cancel` (an event) withdraws an open request on +// timeout / interrupt / session close; `open_requests` on `session.resume` / +// `session.events.since` re-delivers unanswered ones after a reconnect. -export interface RequestExpirePayload { - request_id: string +/** `request.cancel` payload — the backend withdrew an open server request. */ +export interface RequestCancelPayload { + id: string + method: string + reason: string } export interface ClarifyQuestion { @@ -318,54 +328,113 @@ export interface ClarifyQuestion { question: string } -export interface ClarifyRequestPayload { +/** `clarify` params. Single question: `question`/`choices`(/`multi_select`); batch: `questions`. + * `answers` rides along only on a reconnect replay (locks the server already accepted). */ +export interface ClarifyRequestParams { answers?: Record<string, string> choices?: null | string[] multi_select?: boolean question?: string questions?: ClarifyQuestion[] - request_id: string } -/** `tui_gateway/server.py::_approval_request_payload` (command redacted server-side). */ -export interface ApprovalRequestPayload { +/** `clarify` result. Single: `{answer}` ('' = skip). Batch: the request resolves through + * `clarify.lock` RPCs (the last lock completes it); a response with no `answers` is cancel-all. */ +export interface ClarifyResult { + answer?: string + answers?: Record<string, string> +} + +/** `approval` params (`tui_gateway/server.py::_approval_request_payload`, command redacted server-side). */ +export interface ApprovalRequestParams { allow_permanent?: boolean choices?: string[] command: string description: string - request_id?: string + request_id: string smart_denied?: boolean } -export interface SudoRequestPayload { - request_id: string +export interface ApprovalResult { + all?: boolean + choice: 'always' | 'deny' | 'once' | 'session' } -export interface SecretRequestPayload { +/** Every prompt whose answer is one string: `sudo`, `secret`, the vault prompts, the desktop GUI + * bridges (`terminal.read`, `preview.read`, `preview.act`, `window.read`, `tour`) and `mcp.setup`. + * '' means skipped / declined. */ +export interface ValueResult { + value: string +} + +export interface SecretRequestParams { env_var: string + metadata?: Record<string, unknown> prompt: string - request_id: string } -export interface VaultUnlockRequestPayload { +export interface VaultUnlockRequestParams { backend: string display_name: string - request_id: string } -export interface VaultCodeRequestPayload { +export interface VaultSaveLoginRequestParams { + origin: string + site: string +} + +export interface VaultCodeRequestParams { hint?: string - request_id: string site?: string } -export interface McpSetupRequestPayload { +export interface McpSetupRequestParams { action?: string reason?: string - request_id: string server?: string } +export interface ReadRangeRequestParams { + count?: number + start?: number +} + +/** Server→client request method → `{params, result}`. Every method the backend can ask. */ +export interface ServerRequestMap { + approval: { params: ApprovalRequestParams; result: ApprovalResult } + clarify: { params: ClarifyRequestParams; result: ClarifyResult } + 'mcp.setup': { params: McpSetupRequestParams; result: ValueResult } + 'preview.act': { params: Record<string, unknown>; result: ValueResult } + 'preview.read': { params: ReadRangeRequestParams; result: ValueResult } + secret: { params: SecretRequestParams; result: ValueResult } + sudo: { params: Record<string, never>; result: ValueResult } + 'terminal.read': { params: ReadRangeRequestParams; result: ValueResult } + tour: { params: Record<string, unknown>; result: ValueResult } + 'vault.code': { params: VaultCodeRequestParams; result: ValueResult } + 'vault.save_login': { params: VaultSaveLoginRequestParams; result: ValueResult } + 'vault.unlock_prompt': { params: VaultUnlockRequestParams; result: ValueResult } + 'window.read': { params: Record<string, never>; result: ValueResult } +} + +export type ServerRequestMethod = keyof ServerRequestMap + +/** Pinned to `gateway-events.json`'s `server_requests` list by the two contract tests. Keep sorted. */ +export const SERVER_REQUEST_METHODS = [ + 'approval', + 'clarify', + 'mcp.setup', + 'preview.act', + 'preview.read', + 'secret', + 'sudo', + 'terminal.read', + 'tour', + 'vault.code', + 'vault.save_login', + 'vault.unlock_prompt', + 'window.read' +] as const satisfies readonly ServerRequestMethod[] + /** Side agents (`tui_gateway/methods_prompt.py::_spawn_side_agent`). */ export interface SideAgentCompletePayload { question?: string @@ -397,7 +466,6 @@ export interface TerminalClosePayload { */ export const BACKEND_EVENT_NAMES = [ 'agent.terminal.output', - 'approval.request', 'background.complete', 'billing.step_up.verification', 'bot_relay.outbox.pending', @@ -405,14 +473,10 @@ export const BACKEND_EVENT_NAMES = [ 'browser.controller.command', 'browser.progress', 'btw.complete', - 'clarify.expire', - 'clarify.request', 'cron.changed', 'error', 'gateway.ready', 'layout.apply', - 'mcp.setup.expire', - 'mcp.setup.request', 'message.complete', 'message.delta', 'message.interim', @@ -431,20 +495,15 @@ export const BACKEND_EVENT_NAMES = [ 'pet.generate.progress', 'pet.hatch.progress', 'platforms.changed', - 'preview.act.expire', - 'preview.act.request', 'preview.close', 'preview.open', - 'preview.read.expire', - 'preview.read.request', 'preview.restart.complete', 'preview.restart.progress', 'reaction', 'reasoning.available', 'reasoning.delta', + 'request.cancel', 'review.summary', - 'secret.expire', - 'secret.request', 'session.control.update', 'session.info', 'session.reclaimed', @@ -461,11 +520,7 @@ export const BACKEND_EVENT_NAMES = [ 'subagent.start', 'subagent.thinking', 'subagent.tool', - 'sudo.expire', - 'sudo.request', 'terminal.close', - 'terminal.read.expire', - 'terminal.read.request', 'thinking.delta', 'tip.show', 'todo.updated', @@ -473,20 +528,10 @@ export const BACKEND_EVENT_NAMES = [ 'tool.generating', 'tool.output_risk', 'tool.start', - 'tour.expire', - 'tour.request', - 'vault.code.expire', - 'vault.code.request', - 'vault.save_login.expire', - 'vault.save_login.request', - 'vault.unlock.expire', - 'vault.unlock.request', 'voice.interrupted', 'voice.status', 'voice.transcript', - 'wake.detected', - 'window.read.expire', - 'window.read.request' + 'wake.detected' ] as const satisfies readonly (keyof BackendGatewayEventMap)[] export type BackendGatewayEventName = (typeof BACKEND_EVENT_NAMES)[number] @@ -494,7 +539,6 @@ export type BackendGatewayEventName = (typeof BACKEND_EVENT_NAMES)[number] /** Payload per backend-emitted notification `type`. Keys are exactly `BACKEND_EVENT_NAMES`. */ export interface BackendGatewayEventMap { 'agent.terminal.output': TerminalOutputPayload - 'approval.request': ApprovalRequestPayload 'background.complete': SideAgentCompletePayload 'billing.step_up.verification': BillingStepUpVerificationPayload 'bot_relay.outbox.pending': Record<string, unknown> @@ -502,14 +546,10 @@ export interface BackendGatewayEventMap { 'browser.controller.command': Record<string, unknown> 'browser.progress': BrowserProgressPayload 'btw.complete': SideAgentCompletePayload - 'clarify.expire': RequestExpirePayload - 'clarify.request': ClarifyRequestPayload 'cron.changed': Record<string, unknown> error: ErrorPayload 'gateway.ready': GatewayReadyPayload 'layout.apply': Record<string, unknown> - 'mcp.setup.expire': RequestExpirePayload - 'mcp.setup.request': McpSetupRequestPayload 'message.complete': MessageCompletePayload 'message.delta': StreamDeltaPayload 'message.interim': MessageInterimPayload @@ -528,20 +568,15 @@ export interface BackendGatewayEventMap { 'pet.generate.progress': Record<string, unknown> 'pet.hatch.progress': Record<string, unknown> 'platforms.changed': Record<string, unknown> - 'preview.act.expire': RequestExpirePayload - 'preview.act.request': Record<string, unknown> 'preview.close': Record<string, unknown> 'preview.open': Record<string, unknown> - 'preview.read.expire': RequestExpirePayload - 'preview.read.request': Record<string, unknown> 'preview.restart.complete': SideAgentCompletePayload 'preview.restart.progress': PreviewRestartProgressPayload reaction: ReactionPayload 'reasoning.available': StreamDeltaPayload 'reasoning.delta': StreamDeltaPayload + 'request.cancel': RequestCancelPayload 'review.summary': TextPayload - 'secret.expire': RequestExpirePayload - 'secret.request': SecretRequestPayload 'session.control.update': SessionControlUpdatePayload /** Surface-specific shape (`tui_gateway/server.py::_session_info`); each client narrows. */ 'session.info': Record<string, unknown> @@ -559,11 +594,7 @@ export interface BackendGatewayEventMap { 'subagent.start': SubagentEventPayload 'subagent.thinking': SubagentEventPayload 'subagent.tool': SubagentEventPayload - 'sudo.expire': RequestExpirePayload - 'sudo.request': SudoRequestPayload 'terminal.close': TerminalClosePayload - 'terminal.read.expire': RequestExpirePayload - 'terminal.read.request': Record<string, unknown> 'thinking.delta': StreamDeltaPayload 'tip.show': Record<string, unknown> 'todo.updated': TodoStatePayload @@ -571,20 +602,10 @@ export interface BackendGatewayEventMap { 'tool.generating': ToolGeneratingPayload 'tool.output_risk': ToolOutputRiskPayload 'tool.start': ToolStartPayload - 'tour.expire': RequestExpirePayload - 'tour.request': Record<string, unknown> - 'vault.code.expire': RequestExpirePayload - 'vault.code.request': VaultCodeRequestPayload - 'vault.save_login.expire': RequestExpirePayload - 'vault.save_login.request': Record<string, unknown> - 'vault.unlock.expire': RequestExpirePayload - 'vault.unlock.request': VaultUnlockRequestPayload 'voice.interrupted': Record<string, unknown> 'voice.status': VoiceStatusPayload 'voice.transcript': VoiceTranscriptPayload 'wake.detected': WakeDetectedPayload - 'window.read.expire': RequestExpirePayload - 'window.read.request': Record<string, unknown> } /** @@ -757,6 +778,11 @@ export interface SessionResumeResponse<Info = Record<string, unknown>, Message = messages: Message[] /** `omit_messages` resume: the client still learns the stored size. */ messages_omitted?: boolean + /** Server→client requests still unanswered for this session (a clarify, sudo prompt, … + * raised while the client was detached); the client re-delivers them to its request + * handlers. `pending_approval` (the approval queue's oldest entry) is the approval twin. */ + open_requests?: OpenServerRequest[] + pending_approval?: ApprovalRequestParams resumed?: string running?: boolean session_id: string @@ -765,3 +791,10 @@ export interface SessionResumeResponse<Info = Record<string, unknown>, Message = status?: string todo_state?: TodoStatePayload } + +/** One unanswered server→client request as returned by `open_requests`. */ +export interface OpenServerRequest { + id: string + method: string + params: Record<string, unknown> & { session_id?: string } +} -
apps/shared/src/index.ts@@ -62,12 +62,14 @@ export { export { compactNumber } from './format' export { type FuzzyMatch, fuzzyRank, fuzzyScore, fuzzyScoreMulti, type RankedItem } from './fuzzy' export { - type ApprovalRequestPayload, + type ApprovalRequestParams, + type ApprovalResult, BACKEND_EVENT_NAMES, type BackendGatewayEventMap, type BackendGatewayEventName, type ClarifyQuestion, - type ClarifyRequestPayload, + type ClarifyRequestParams, + type ClarifyResult, type ClientLocalGatewayEventMap, type ErrorPayload, type ErrorSurface, @@ -76,7 +78,7 @@ export { type GatewayEventName, type GatewayReadyPayload, type GatewayTranscriptMessage, - type McpSetupRequestPayload, + type McpSetupRequestParams, type MessageCompletePayload, type MessageInterimPayload, type ModelCapabilities, @@ -84,8 +86,13 @@ export { type ModelOptionsResponse, type ModelPricing, type NotificationShowPayload, - type RequestExpirePayload, - type SecretRequestPayload, + type OpenServerRequest, + type ReadRangeRequestParams, + type RequestCancelPayload, + type SecretRequestParams, + SERVER_REQUEST_METHODS, + type ServerRequestMap, + type ServerRequestMethod, type SessionInflightTurn, type SessionListItem, type SessionListResponse, @@ -100,7 +107,10 @@ export { type ToolCompletePayload, type ToolStartPayload, type Usage, - type VaultUnlockRequestPayload, + type ValueResult, + type VaultCodeRequestParams, + type VaultSaveLoginRequestParams, + type VaultUnlockRequestParams, type WakeDetectedPayload } from './gateway-events' export { @@ -124,6 +134,9 @@ export { JsonRpcRequestChannel, type JsonRpcRequestChannelOptions, type JsonRpcTransport, + type ServerRequest, + type ServerRequestHandler, + type ServerRequestParams, wireFrameText } from './json-rpc-channel' export { -
apps/shared/src/json-rpc-channel.test.ts@@ -170,4 +170,50 @@ describe('JsonRpcRequestChannel', () => { vi.useRealTimers() } }) + + // Server→client requests (tui_gateway/server_requests.py): the backend asks, + // the client answers with a RESPONSE frame carrying the same id. + 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) }) + const { sent, transport } = spyTransport() + + channel.attach(transport) + channel.onRequest(req => (req.method === 'clarify' ? void req.respond({ answer: 'yes' }) : false)) + + channel.handleFrame(JSON.stringify({ id: 'srq-1', jsonrpc: '2.0', method: 'clarify', params: { session_id: 's1' } })) + channel.handleFrame(JSON.stringify({ id: 'srq-2', jsonrpc: '2.0', method: 'tour', params: { session_id: 's1' } })) + + const frames = sent.map(f => JSON.parse(f) as { id: string; result?: unknown; error?: { code: number } }) + + expect(frames[0]).toEqual({ id: 'srq-1', jsonrpc: '2.0', result: { answer: 'yes' } }) + expect(frames[1].id).toBe('srq-2') + expect(frames[1].error?.code).toBe(-32601) + expect(unhandled).toEqual(['tour']) + }) + + it('re-delivers open_requests from a response before the caller sees the result, tagged replayed', async () => { + const delivered: Array<{ id: string; replayed?: boolean }> = [] + const channel = new JsonRpcRequestChannel() + const { sent, transport } = spyTransport() + + channel.attach(transport) + channel.onRequest(req => void delivered.push({ id: req.id, replayed: req.replayed })) + + const resume = channel.request<{ session_id: string }>('session.resume', { session_id: 's1' }) + const rid = (JSON.parse(sent.at(-1)!) as { id: string }).id + + channel.handleFrame( + JSON.stringify({ + id: rid, + jsonrpc: '2.0', + result: { + open_requests: [{ id: 'srq-9', method: 'sudo', params: { session_id: 's1' } }], + session_id: 's1' + } + }) + ) + await expect(resume).resolves.toMatchObject({ session_id: 's1' }) + expect(delivered).toEqual([{ id: 'srq-9', replayed: true }]) + }) }) -
apps/shared/src/json-rpc-channel.ts@@ -12,10 +12,47 @@ export interface JsonRpcFrame { error?: JsonRpcErrorPayload id?: GatewayRequestId | null method?: string - params?: GatewayEvent + params?: GatewayEvent | ServerRequestParams result?: unknown } +/** + * Params of a server→client request (`tui_gateway/server_requests.py`): the + * backend asking the renderer a question. `session_id` names the session + * blocked on the answer; the rest is method-specific. + */ +export interface ServerRequestParams extends Record<string, unknown> { + session_id?: string +} + +/** One inbound server→client request, as handed to a `ServerRequestHandler`. */ +export interface ServerRequest<M extends string = string, P extends ServerRequestParams = ServerRequestParams> { + id: string + method: M + params: P + /** + * Route the answer back to the backend that asked. Idempotent: the first + * `respond` (or `fail`) wins; a request re-delivered after a reconnect + * (`open_requests`) reuses the id, so a stale card answering twice is a + * no-op on the wire. + */ + respond: (result: Record<string, unknown>) => void + /** Answer with a JSON-RPC error (the backend treats it as unanswered). */ + fail: (code: number, 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 + * card can skip re-notifying. + */ + replayed?: boolean +} + +/** Handles one inbound server→client request; return `false` to decline (next handler tries). */ +export type ServerRequestHandler = (request: ServerRequest) => boolean | void + +const isServerRequestFrame = (frame: JsonRpcFrame): frame is JsonRpcFrame & { id: string; method: string } => + typeof frame.id === 'string' && typeof frame.method === 'string' && frame.method !== 'event' + /** JSON-RPC error with optional structured `data` from the gateway. */ export class JsonRpcGatewayError extends Error { readonly code?: number @@ -59,6 +96,12 @@ export interface JsonRpcRequestChannelOptions { onHeartbeatFailure?: (error: Error) => void /** Decoded `event` notification. */ onEvent?: (event: GatewayEvent) => void + /** + * Inbound server→client request nobody handled: the owner logs it. The + * channel has already answered `-32601` so the backend does not wait out + * its deadline against a client with no handler. + */ + onUnhandledRequest?: (request: { id: string; method: string; params: ServerRequestParams }) => void requestIdPrefix?: string requestTimeoutMs?: number /** @@ -130,8 +173,11 @@ export class JsonRpcRequestChannel { private heartbeatSequence = 0 private readonly outstandingPings = new Set<string>() private lastLivenessAt = 0 - private readonly options: Required<Omit<JsonRpcRequestChannelOptions, 'onEvent' | 'onHeartbeatFailure'>> & - Pick<JsonRpcRequestChannelOptions, 'onEvent' | 'onHeartbeatFailure'> + private readonly requestHandlers: ServerRequestHandler[] = [] + private readonly options: Required< + Omit<JsonRpcRequestChannelOptions, 'onEvent' | 'onHeartbeatFailure' | 'onUnhandledRequest'> + > & + Pick<JsonRpcRequestChannelOptions, 'onEvent' | 'onHeartbeatFailure' | 'onUnhandledRequest'> constructor(options: JsonRpcRequestChannelOptions = {}) { this.options = { @@ -141,6 +187,7 @@ export class JsonRpcRequestChannel { heartbeatLiveness: options.heartbeatLiveness ?? 'response', onEvent: options.onEvent, onHeartbeatFailure: options.onHeartbeatFailure, + onUnhandledRequest: options.onUnhandledRequest, requestIdPrefix: options.requestIdPrefix ?? 'r', requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, unrefTimers: options.unrefTimers ?? false @@ -254,11 +301,89 @@ export class JsonRpcRequestChannel { }) } + /** + * Register a handler for server→client requests (clarify, approval, sudo, + * …). Handlers are tried in registration order until one accepts (returns + * anything but `false`); an unhandled request is answered `-32601` so the + * backend never waits out its deadline against a client that cannot answer. + */ + onRequest(handler: ServerRequestHandler): () => void { + this.requestHandlers.push(handler) + + return () => { + const index = this.requestHandlers.indexOf(handler) + + if (index >= 0) { + this.requestHandlers.splice(index, 1) + } + } + } + + /** + * Deliver a server request to the handlers. Live frames arrive through + * `handleFrame`; owners call this directly for `open_requests` returned by a + * reconnect replay (`replayed: true`) so an unanswered question survives a + * dropped socket. + */ + deliverRequest(id: string, method: string, params: ServerRequestParams, replayed = false): boolean { + let settled = false + + const send = (frame: Record<string, unknown>) => { + if (settled) { + return + } + + settled = true + + try { + this.transport?.send(JSON.stringify({ jsonrpc: '2.0', id, ...frame })) + } catch { + // The generation is gone; the backend withdraws the request itself (timeout / reconnect replay). + } + } + + const request: ServerRequest = { + id, + method, + params, + replayed, + respond: result => send({ result }), + fail: (code, message) => send({ error: { code, message } }) + } + + for (const handler of this.requestHandlers) { + if (handler(request) !== false) { + return true + } + } + + request.fail(JSON_RPC_METHOD_NOT_FOUND, `no handler for server request: ${method}`) + this.options.onUnhandledRequest?.({ id, method, params }) + + return false + } + + private deliverOpenRequests(result: unknown): void { + const open = (result as { open_requests?: unknown } | null)?.open_requests + + if (!Array.isArray(open)) { + return + } + + for (const entry of open as Array<{ id?: unknown; method?: unknown; params?: unknown }>) { + if (typeof entry?.id === 'string' && typeof entry.method === 'string') { + const params = entry.params && typeof entry.params === 'object' ? (entry.params as ServerRequestParams) : {} + this.deliverRequest(entry.id, entry.method, params, true) + } + } + } + /** * Route one inbound frame: a response settles its pending call, an - * `event` notification reaches `onEvent`. Returns the decoded frame so the - * owner can act on it too (mirror it, record seq, …) or `null` when the - * text was not JSON or not a JSON object (`null`, a scalar). + * `event` notification reaches `onEvent`, a server→client request reaches + * the `onRequest` handlers. Returns the decoded frame so the owner can act + * on it too (mirror it, record seq, …) or `null` when the text was not + * JSON or not a JSON object (`null`, a scalar). */ handleFrame(text: string): JsonRpcFrame | null { let frame: JsonRpcFrame @@ -277,6 +402,13 @@ export class JsonRpcRequestChannel { this.lastLivenessAt = Date.now() } + if (isServerRequestFrame(frame)) { + const params = frame.params && typeof frame.params === 'object' ? (frame.params as ServerRequestParams) : {} + this.deliverRequest(frame.id, frame.method, params) + + return frame + } + if (frame.id !== undefined && frame.id !== null) { if (typeof frame.id === 'string' && this.outstandingPings.delete(frame.id)) { this.lastLivenessAt = Date.now() @@ -293,6 +425,13 @@ export class JsonRpcRequestChannel { if (frame.error) { call.reject(jsonRpcErrorFromFrame(frame.error)) } else { + // Reconnect contract: `session.resume` / `session.activate` / + // `session.events.since` answer with `open_requests` — the server→ + // client requests still waiting on this session. They cannot ride + // the event replay ring (they are not events), so they are re- + // delivered here, before the caller sees the result, over the very + // socket that owns them. + this.deliverOpenRequests(frame.result) call.resolve(frame.result) } } @@ -300,8 +439,8 @@ export class JsonRpcRequestChannel { return frame } - if (frame.method === 'event' && frame.params && typeof frame.params.type === 'string') { - this.options.onEvent?.(frame.params) + if (frame.method === 'event' && frame.params && typeof (frame.params as GatewayEvent).type === 'string') { + this.options.onEvent?.(frame.params as GatewayEvent) } return frame -
apps/shared/src/json-rpc-gateway.ts@@ -5,6 +5,7 @@ import { type GatewayRequestId, JsonRpcRequestChannel, type JsonRpcTransport, + type ServerRequestHandler, wireFrameText } from './json-rpc-channel.js' @@ -343,6 +344,15 @@ export class JsonRpcGatewayClient { return this.onAny(handler) } + /** + * Server→client requests (clarify, approval, sudo, …). Live frames and + * `open_requests` re-delivered after a reconnect both arrive here; the + * latter carry `replayed: true`. + */ + onRequest(handler: ServerRequestHandler): () => void { + return this.channel.onRequest(handler) + } + onState(handler: (state: ConnectionState) => void): () => void { this.stateHandlers.add(handler) handler(this.state) @@ -445,6 +455,7 @@ export class JsonRpcGatewayClient { // One RPC per known session keeps params flat; sessions are few (<20). const results = await Promise.allSettled( entries.map(([sid, lastSeen]) => + // `open_requests` on the answer are re-delivered by the channel itself. this.request<{ events?: Array<{ type: string; session_id?: string; seq?: number; payload?: unknown }> }>( 'session.events.since', { session_id: sid, last_seen: lastSeen }, -
tests/gateway/test_tui_approval_redaction.py@@ -15,24 +15,28 @@ class TestTuiApprovalEmitRedaction: - def test_emit_approval_request_redacts_command_in_payload(self, monkeypatch): - from tui_gateway import server as tui_server + @staticmethod + def _sent(monkeypatch): + """Capture the ``approval`` server request frame ``_emit_approval_request`` sends.""" + from tui_gateway import server as tui_server, server_requests + + sent = {} + monkeypatch.setattr(server_requests, "send_async", + lambda method, sid, params, on_result: (sent.update(method=method, sid=sid, params=params), + lambda reason: None)[1]) + monkeypatch.setattr(tui_server, "_sessions", {"sess-1": {"session_key": "key-1"}}) + return tui_server, sent - emitted = {} - monkeypatch.setattr( - tui_server, "_emit", - lambda event, sid, payload=None: emitted.update( - {"event": event, "sid": sid, "payload": payload} - ), - ) + def test_emit_approval_request_redacts_command_in_payload(self, monkeypatch): + tui_server, sent = self._sent(monkeypatch) raw = "curl -H 'Authorization: token ghp_01...6789' https://api.github.com" tui_server._emit_approval_request("sess-1", {"command": raw, "description": "x"}) - assert emitted["event"] == "approval.request" + assert sent["method"] == "approval" and sent["sid"] == "sess-1" # credential removed, non-command field + command structure preserved - assert "ghp_01...6789" not in emitted["payload"]["command"] - assert emitted["payload"]["description"] == "x" - assert "github.com" in emitted["payload"]["command"] + assert "ghp_01...6789" not in sent["params"]["command"] + assert sent["params"]["description"] == "x" + assert "github.com" in sent["params"]["command"] @pytest.mark.parametrize( ("allow_session", "allow_permanent", "expected"), @@ -45,23 +49,10 @@ def test_emit_approval_request_redacts_command_in_payload(self, monkeypatch): def test_emit_approval_request_honors_allowed_scopes( self, monkeypatch, allow_session, allow_permanent, expected ): - from tui_gateway import server as tui_server - - emitted = {} - monkeypatch.setattr( - tui_server, - "_emit", - lambda event, sid, payload=None: emitted.update({"payload": payload}), - ) - + tui_server, sent = self._sent(monkeypatch) tui_server._emit_approval_request( "sess-1", - { - "allow_permanent": allow_permanent, - "allow_session": allow_session, - "command": "<write to AGENTS.md>", - }, + {"allow_permanent": allow_permanent, "allow_session": allow_session, "command": "<write to AGENTS.md>"}, ) - assert emitted["payload"]["choices"] == expected - + assert sent["params"]["choices"] == expected -
tests/tui_gateway/test_compute_host_phase1.py@@ -48,40 +48,38 @@ def test_compute_host_workers_inherit_tui_pool_env_or_8(monkeypatch): assert _default_workers() == 8 -def test_compute_host_routes_clarify_response_to_child_pending_registry(monkeypatch): - """Interactive answers are handled in the process that owns `_pending`.""" +def test_compute_host_routes_relayed_response_and_lock_to_its_open_request(monkeypatch): + """The child owns the server request's wait: a relayed client response frame resolves it in-process, + and a relayed ``clarify.lock`` is answered with that method's result for the parent to ack.""" + from tui_gateway import server_requests out = io.StringIO() host = ComputeHost(stdout=out, heartbeat_secs=0) sid = "host-clarify" server._sessions[sid] = {"history_lock": threading.Lock()} - calls = [] - monkeypatch.setitem( - server._methods, - "clarify.respond", - lambda rid, params: calls.append((rid, dict(params))) or {"result": {"status": "ok"}}, - ) + req = server_requests.ServerRequest(sid, "clarify", {"question": "?"}) + with server_requests._lock: + server_requests._open[req.id] = req + locks = [] + monkeypatch.setitem(server._methods, "clarify.lock", + lambda rid, params: locks.append((rid, dict(params))) or {"result": {"status": "ok", "remaining": []}}) try: - host._handle_respond( - { - "sid": sid, - "request_id": "relay-response", - "params": {"request_id": "clarify-request", "answer": "yes"}, - } - ) - assert calls == [("relay-response", {"request_id": "clarify-request", "answer": "yes"})] + host._handle_respond({"sid": sid, "request_id": "relay-lock", + "params": {"lock": {"request_id": req.id, "question_id": "q0", "answer": "a"}}}) + assert locks == [("relay-lock", {"request_id": req.id, "question_id": "q0", "answer": "a"})] + assert _json_lines(out)[-1]["response"] == {"result": {"status": "ok", "remaining": []}} + + host._handle_respond({"sid": sid, "request_id": "relay-response", + "params": {"frame": {"jsonrpc": "2.0", "id": req.id, "result": {"answer": "yes"}}}}) + assert req.answered and req.result == {"answer": "yes"} and req.event.is_set() frame = _json_lines(out)[-1] - assert frame == { - "type": "respond.ack", - "sid": sid, - "request_id": "relay-response", - "response": {"result": {"status": "ok"}}, - "host_ns": frame["host_ns"], - } + assert frame["type"] == "respond.ack" and frame["response"]["result"] == {"status": "ok"} finally: server._sessions.pop(sid, None) + server_requests.reset_for_tests() host.close() + def test_mutator_route_table_matches_prd_inventory(): assert MUTATOR_ROUTE_TABLE == { "prompt.submit": "turn-path", -
tests/tui_gateway/test_gateway_event_contract.py@@ -1,16 +1,17 @@ -"""Two-sided contract: every notification name ``tui_gateway`` emits is listed in -``apps/shared/src/gateway-events.json`` and nothing in the JSON is orphaned. +"""Two-sided contract: every notification name ``tui_gateway`` emits, and every +server→client request method it sends, is listed in ``apps/shared/src/gateway-events.json`` +(``events`` / ``server_requests``) and nothing in the JSON is orphaned. The TypeScript half (``apps/shared/src/gateway-events.test.ts``) pins the typed -``GatewayEventMap`` to the same JSON, so a name added on either side alone goes red -somewhere. This file reads only Python sources and the JSON (never ``.ts`` text — -see ``tui_gateway/AGENTS.md``). - -Names are collected from the emitter side: literal first arguments to the emit -helpers, plus the tables that derive names at runtime (``_EXPIRING_REQUESTS`` → -``*.expire``, the change-watcher table, child delta mirroring, the subagent relay -events from ``tools/delegate_tool*.py``, the ``desktop_ui`` tool emitters, and the -literal ``gateway.ready`` / ``setup.ready`` / browser-controller frames). +``GatewayEventMap`` and ``ServerRequestMap`` to the same JSON, so a name added on either +side alone goes red somewhere. This file reads only Python sources and the JSON (never +``.ts`` text — see ``tui_gateway/AGENTS.md``). + +Names are collected from the emitter side: literal first arguments to the emit helpers +and the server-request helpers (``server_requests.send`` / ``send_async`` / ``_ask``), +plus the tables that derive names at runtime (the change-watcher table, child delta +mirroring, the subagent relay events from ``tools/delegate_tool*.py``, the ``desktop_ui`` +tool emitters, and the literal ``gateway.ready`` / ``setup.ready`` / browser-controller frames). """ from __future__ import annotations @@ -26,9 +27,12 @@ GATEWAY_DIR = REPO / "tui_gateway" # Every helper whose first positional argument is the wire ``type``. -_EMIT_HELPERS = ( - "_emit", "_block", "_read_block", "_broadcast_global_event", "_voice_emit", "_pet_emit", "_emit_tool_lifecycle") +_EMIT_HELPERS = ("_emit", "_broadcast_global_event", "_voice_emit", "_pet_emit", "_emit_tool_lifecycle") _LITERAL_EMIT = re.compile(r"\b(?:%s)\(\s*\"([a-z_][a-z0-9_.]*)\"" % "|".join(_EMIT_HELPERS)) +# Server→client requests: ``server_requests.send("x", …)`` / ``send_async`` / the string-answer ``_ask`` and +# ``_read_block`` bridges. +_REQUEST_HELPERS = ("server_requests\\.send", "server_requests\\.send_async", "_ask", "_read_block") +_LITERAL_REQUEST = re.compile(r"\b(?:%s)\(\s*\"([a-z_][a-z0-9_.]*)\"" % "|".join(_REQUEST_HELPERS)) # ``{"type": "gateway.ready", ...}`` literal frames (entry.py / ws.py) and other # ``"type": "<name>"`` params written straight into an ``event`` frame. _LITERAL_FRAME = re.compile(r"\"method\":\s*\"event\".{0,120}?\"type\":\s*\"([a-z_][a-z0-9_.]*)\"", re.S) @@ -50,10 +54,6 @@ def emitted_event_names() -> set[str]: names.update(_LITERAL_EMIT.findall(text)) names.update(_LITERAL_FRAME.findall(text)) names.update(_SIDE_AGENT.findall(text)) - # ``.request`` bridges that time out fire ``f"{event.removesuffix('.request')}.expire"``. - from tui_gateway.server import _EXPIRING_REQUESTS - - names.update(f"{event.removesuffix('.request')}.expire" for event in _EXPIRING_REQUESTS) from tui_gateway.change_watcher import _CHANGE_WATCHES names.update(_CHANGE_WATCHES) @@ -74,13 +74,33 @@ def emitted_event_names() -> set[str]: return names +def server_request_methods() -> set[str]: + names: set[str] = set() + for src in GATEWAY_DIR.glob("*.py"): + names.update(_LITERAL_REQUEST.findall(_read(src))) + return names + + @pytest.fixture(scope="module") def contract() -> list[str]: - return json.loads(_read(CONTRACT)) + return json.loads(_read(CONTRACT))["events"] + + [redacted:email](scope="module") +def request_contract() -> list[str]: + return json.loads(_read(CONTRACT))["server_requests"] + + +def test_contract_is_sorted_and_unique(contract, request_contract): + assert contract == sorted(set(contract)), "gateway-events.json events must be a sorted, duplicate-free list" + assert request_contract == sorted(set(request_contract)), "gateway-events.json server_requests must be sorted" -def test_contract_is_sorted_and_unique(contract): - assert contract == sorted(set(contract)), "gateway-events.json must be a sorted, duplicate-free list" +def test_server_request_methods_match_the_contract(request_contract): + sent = server_request_methods() + assert sent == set(request_contract), ( + f"server requests sent {sorted(sent)} vs gateway-events.json server_requests {request_contract}; " + "fix the JSON AND SERVER_REQUEST_METHODS / ServerRequestMap in apps/shared/src/gateway-events.ts") def test_every_emitted_event_is_in_the_contract(contract): -
tests/tui_gateway/test_goal_command.py@@ -70,8 +70,7 @@ def server(hermes_home, monkeypatch): # _enter_buffered_busy. Clearing the per-session dicts gives the # next test a clean slate. mod._sessions.clear() - mod._pending.clear() - mod._answers.clear() + __import__("tui_gateway.server_requests", fromlist=["x"]).reset_for_tests() @pytest.fixture() -
tests/tui_gateway/test_hosted_room_member_activity_hook.py@@ -57,7 +57,10 @@ def test_room_member_session_events_reach_plugins_with_room_coordinates(observer _session("room-sid", hosted=True) try: server._emit("tool.start", "room-sid", {"tool_id": "call-1", "name": "terminal", "args": {"command": "ls"}}) - server._emit("approval.request", "room-sid", {"request_id": "req-1", "command": "rm -rf build"}) + # An approval is a server→client REQUEST frame, not an event; it is member activity all the same. + from tui_gateway import server_requests + server_requests.send_async("approval", "room-sid", {"request_id": "req-1", "command": "rm -rf build"}, + lambda result: None)("test_done") server._emit("session.info", "room-sid", {"title": "chrome, not member activity"}) server._emit("tool.complete", "room-sid", {"tool_id": "call-1", "name": "terminal", "result": "ok"}) finally: @@ -71,8 +74,9 @@ def test_room_member_session_events_reach_plugins_with_room_coordinates(observer assert {k: started[k] for k in HOSTED_TASK} == HOSTED_TASK assert started["payload"]["tool_id"] == "call-1" and started["payload"]["args"] == {"command": "ls"} assert observer[1]["payload"]["request_id"] == "req-1" - # Per-session replay seq travels with the event so consumers can order/dedupe. - assert [event["seq"] for event in observer] == sorted(event["seq"] for event in observer) + # Per-session replay seq travels with the events so consumers can order/dedupe (a request frame carries none). + seqs = [event["seq"] for event in observer if event["seq"] is not None] + assert seqs == sorted(seqs) and len(seqs) == 2 def test_ordinary_session_events_never_fire_the_room_hook(observer): -
tests/tui_gateway/test_inline_rpc_gil_starvation.py@@ -56,8 +56,7 @@ def server(): mod._methods.update(methods) mod._real_stdout = real_stdout mod._sessions.clear() - mod._pending.clear() - mod._answers.clear() + __import__("tui_gateway.server_requests", fromlist=["x"]).reset_for_tests() @pytest.fixture() -
tests/tui_gateway/test_loop_command.py@@ -44,8 +44,7 @@ def server(hermes_home): mod = importlib.import_module("tui_gateway.server") yield mod mod._sessions.clear() - mod._pending.clear() - mod._answers.clear() + __import__("tui_gateway.server_requests", fromlist=["x"]).reset_for_tests() @pytest.fixture() -
tests/tui_gateway/test_protocol.py@@ -56,8 +56,8 @@ def server(): mod._real_stdout = real_stdout for sid in list(mod._sessions): mod._close_session_by_id(sid, end_reason="test_cleanup") - mod._pending.clear() - mod._answers.clear() + from tui_gateway import server_requests + server_requests.reset_for_tests() mod._live_transports.clear() @@ -219,42 +219,26 @@ def test_live_session_payload_replays_pending_approval(server, monkeypatch): assert replayed == first -def test_live_session_payload_replays_pending_clarify(server): - """A reattached client also receives a clarify question emitted while detached.""" +def test_live_session_payload_replays_open_requests(server): + """A reattached client receives the server→client request still blocking the session (a clarify sent + while detached), scoped to the owning runtime session only, as a snapshot not a live reference.""" + from tui_gateway import server_requests session = { - "agent": types.SimpleNamespace(), - "cols": 80, - "created_at": 1.0, - "history": [], - "history_lock": threading.Lock(), - "running": True, - "session_key": "stored-session", + "agent": types.SimpleNamespace(), "cols": 80, "created_at": 1.0, "history": [], + "history_lock": threading.Lock(), "running": True, "session_key": "stored-session", } - clarify_payload = { - "choices": ["staging", "production"], - "question": "Which deployment target?", - "request_id": "rid-clarify", - } - with server._prompt_lock: - server._pending["rid-clarify"] = ("runtime-session", threading.Event()) - server._pending_prompt_payloads["rid-clarify"] = ( - "clarify.request", - dict(clarify_payload), - ) - + req = server_requests.ServerRequest("runtime-session", "clarify", {"question": "Which?", "choices": ["a", "b"]}) + server_requests._open[req.id] = req try: payload = server._live_session_payload("runtime-session", session) other = server._live_session_payload("other-session", session) finally: - with server._prompt_lock: - server._pending.pop("rid-clarify", None) - server._pending_prompt_payloads.pop("rid-clarify", None) + server_requests._open.pop(req.id, None) - assert payload["pending_clarify"] == clarify_payload - # Snapshot, not a live reference into the registry. - assert payload["pending_clarify"] is not clarify_payload - # Scoped to the owning runtime session only. - assert "pending_clarify" not in other + assert payload["open_requests"] == [{"id": req.id, "method": "clarify", + "params": {"session_id": "runtime-session", "question": "Which?", "choices": ["a", "b"]}}] + assert payload["open_requests"][0]["params"] is not req.params + assert "open_requests" not in other def test_disable_flush_env_var_actually_wires_to_module_constant(monkeypatch): @@ -290,275 +274,145 @@ def test_emit_with_payload(capture): assert msg["params"]["payload"]["key"] == "val" -# ── Blocking prompt round-trip ─────────────────────────────────────── +# ── Server→client requests (tui_gateway/server_requests.py) ───────── -def test_block_and_respond(capture): - server, _ = capture - result = [None] +def _frames(buf): + return [json.loads(line) for line in buf.getvalue().splitlines()] - threading.Thread( - target=lambda: result.__setitem__(0, server._block("test.prompt", "s1", {"q": "?"}, timeout=5)), - ).start() - for _ in range(100): - if server._pending: - break - threading.Event().wait(0.01) - - rid = next(iter(server._pending)) - server._answers[rid] = "my_answer" - # _pending values are (sid, Event) tuples — unpack to set the Event - _, ev = server._pending[rid] - ev.set() - - threading.Event().wait(0.1) - assert result[0] == "my_answer" +def _wait_open(server_requests, buf=None, timeout=2.0): + """The open request once its frame has been written (registration precedes the write).""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + with server_requests._lock: + req = next(iter(server_requests._open.values()), None) + if req is not None and (buf is None or req.id in buf.getvalue()): + return req + time.sleep(0.01) + raise AssertionError("server request never registered") [redacted:email]( - "event", - ["secret.request", "sudo.request", "clarify.request", "terminal.read.request"], -) -def test_sensitive_prompt_timeout_emits_expiry(capture, event): +def test_server_request_round_trip_uses_response_frame(capture): + """The backend sends a real JSON-RPC request (string id, method, params.session_id) and the client's + response frame — not a method call — resolves it; the response frame itself gets no reply.""" + from tui_gateway import server_requests server, buf = capture + box = {} + thread = threading.Thread(target=lambda: box.__setitem__("r", server._ask("sudo", "s1", {}, timeout=5)), daemon=True) + thread.start() + req = _wait_open(server_requests, buf) + frame = _frames(buf)[-1] + assert frame == {"jsonrpc": "2.0", "id": req.id, "method": "sudo", "params": {"session_id": "s1"}} + assert req.id.startswith("srq-") - assert server._block(event, "s1", {}, timeout=0) == "" - - messages = [json.loads(line) for line in buf.getvalue().splitlines()] - request, expiry = [message["params"] for message in messages] - assert request["type"] == event - assert expiry["type"] == event.removesuffix(".request") + ".expire" - assert expiry["session_id"] == "s1" - assert expiry["payload"]["request_id"] == request["payload"]["request_id"] + assert server.dispatch({"jsonrpc": "2.0", "id": req.id, "result": {"value": "hunter2"}}) is None + thread.join(timeout=5) + assert box["r"] == "hunter2" + with server_requests._lock: + assert not server_requests._open [redacted:email]( - ("method", "value_key"), - [ - ("secret.respond", "value"), - ("sudo.respond", "password"), - ("clarify.respond", "answer"), - ("terminal.read.respond", "text"), - ], -) -def test_late_prompt_response_is_idempotent(server, method, value_key): - """All four blocking bridges tolerate a late reply after their request has - expired — the `*.respond` returns a graceful `{"status": "expired"}` instead - of the raw 4009 protocol error a client would otherwise surface verbatim.""" - response = server.handle_request( - { - "id": "late-response", - "method": method, - "params": {"request_id": "expired-request", value_key: ""}, - } - ) - [redacted:email]("method", ["secret", "sudo", "terminal.read", "tour"]) +def test_server_request_timeout_emits_one_request_cancel(capture, method): + from tui_gateway import server_requests + server, buf = capture + assert server_requests.send(method, "s1", {}, timeout=0) is None + request, cancel = _frames(buf) + assert request["method"] == method + assert cancel["params"]["type"] == "request.cancel" + assert cancel["params"]["session_id"] == "s1" + assert cancel["params"]["payload"] == {"id": request["id"], "method": method, "reason": "timeout"} + + +def test_late_response_and_lock_are_dropped_quietly(server): + """A response for an id that already ended is ignored (no reply, no error); a late clarify.lock + answers `expired` instead of a protocol error a card would surface verbatim.""" + assert server.dispatch({"jsonrpc": "2.0", "id": "srq-gone", "result": {"value": ""}}) is None + response = server.handle_request({"id": "late", "method": "clarify.lock", + "params": {"request_id": "srq-gone", "question_id": "q0", "answer": ""}}) assert response["result"] == {"status": "expired"} -# ── clarify batch (multi-question) bridge ──────────────────────────── - - -def _drain_batch_block(server, qids, timeout=5, payload=None): - """Run a batch _block on a worker thread and return (thread, result box, - emitted request payload). The caller resolves questions via - handle_request and then joins.""" +def _start_batch_clarify(server, buf, qids, timeout=None): + from tui_gateway import server_requests box = {} - - def run(): - box["answer"] = server._block( - "clarify.request", - "s1", - dict(payload or {"questions": [{"qid": q, "question": q} for q in qids]}), - timeout=timeout, - batch_qids=list(qids), - ) - - thread = threading.Thread(target=run, daemon=True) + normalized = [{"qid": q, "id": "", "question": q, "choices": None, "choices_offered": [], "multi_select": False} + for q in qids] + if timeout is not None: + server._clarify_timeout_seconds = lambda: timeout + thread = threading.Thread( + target=lambda: box.__setitem__("answer", server._clarify_block("s1", "", None, questions=normalized)), daemon=True) thread.start() - # Wait for the request to be registered so respond calls can find it. - deadline = time.monotonic() + 2 - while time.monotonic() < deadline: - with server._prompt_lock: - if server._batch_clarify: - rid = next(iter(server._batch_clarify)) - return thread, box, rid - time.sleep(0.01) - raise AssertionError("batch clarify request never registered") + return thread, box, _wait_open(server_requests, buf) -def test_clarify_batch_resolves_when_all_questions_locked(capture): +def test_clarify_batch_locks_resolve_in_order_and_keep_partial_on_timeout(capture): + """Per-question locks (clarify.lock) are editable until every qid is locked; the last lock resolves the + request with the full answer set. Only wire fields reach the renderer.""" server, buf = capture - thread, box, rid = _drain_batch_block(server, ["q0", "q1"]) - - first = server.handle_request({ - "id": "a1", "method": "clarify.respond", - "params": {"request_id": rid, "question_id": "q1", "answer": "beta"}, - }) - assert first["result"]["status"] == "ok" - assert first["result"]["remaining"] == ["q0"] - assert thread.is_alive() # one question left — still blocking - - second = server.handle_request({ - "id": "a2", "method": "clarify.respond", - "params": {"request_id": rid, "question_id": "q0", "answer": "alpha"}, - }) - assert second["result"]["status"] == "ok" - assert second["result"]["remaining"] == [] - - thread.join(timeout=5) - assert not thread.is_alive() - assert json.loads(box["answer"]) == {"answers": {"q0": "alpha", "q1": "beta"}} - - -def test_clarify_batch_answer_update_overwrites_before_completion(server): - thread, box, rid = _drain_batch_block(server, ["q0", "q1"]) - - server.handle_request({ - "id": "a1", "method": "clarify.respond", - "params": {"request_id": rid, "question_id": "q0", "answer": "first"}, - }) - server.handle_request({ - "id": "a2", "method": "clarify.respond", - "params": {"request_id": rid, "question_id": "q0", "answer": "changed"}, - }) - server.handle_request({ - "id": "a3", "method": "clarify.respond", - "params": {"request_id": rid, "question_id": "q1", "answer": "done"}, - }) - - thread.join(timeout=5) - assert json.loads(box["answer"])["answers"]["q0"] == "changed" - - -def test_clarify_batch_empty_answer_is_a_locked_skip(server): - """Skipping one question locks an empty answer — it counts toward - completion instead of leaving the batch waiting.""" - thread, box, rid = _drain_batch_block(server, ["q0", "q1"]) - - server.handle_request({ - "id": "a1", "method": "clarify.respond", - "params": {"request_id": rid, "question_id": "q0", "answer": ""}, - }) - server.handle_request({ - "id": "a2", "method": "clarify.respond", - "params": {"request_id": rid, "question_id": "q1", "answer": "kept"}, - }) + thread, box, req = _start_batch_clarify(server, buf, ["q0", "q1"]) + sent = _frames(buf)[-1]["params"]["questions"][0] + assert set(sent) == {"qid", "question", "choices", "multi_select"} + first = server.handle_request({"id": "a1", "method": "clarify.lock", + "params": {"request_id": req.id, "question_id": "q0", "answer": "x"}}) + assert first["result"] == {"status": "ok", "remaining": ["q1"]} + redo = server.handle_request({"id": "a1b", "method": "clarify.lock", + "params": {"request_id": req.id, "question_id": "q0", "answer": "y"}}) + assert redo["result"] == {"status": "ok", "remaining": ["q1"]} + bad = server.handle_request({"id": "bad", "method": "clarify.lock", + "params": {"request_id": req.id, "question_id": "nope", "answer": ""}}) + assert bad["error"]["code"] == 4002 + assert server._open_requests("s1")[0]["params"]["answers"] == {"q0": "y"} + last = server.handle_request({"id": "a2", "method": "clarify.lock", + "params": {"request_id": req.id, "question_id": "q1", "answer": ""}}) + assert last["result"] == {"status": "ok", "remaining": []} thread.join(timeout=5) - assert json.loads(box["answer"]) == {"answers": {"q0": "", "q1": "kept"}} - - -def test_clarify_batch_unknown_question_id_rejected(server): - thread, box, rid = _drain_batch_block(server, ["q0"]) + assert json.loads(box["answer"]) == {"answers": {"q0": "y", "q1": ""}} - response = server.handle_request({ - "id": "bad", "method": "clarify.respond", - "params": {"request_id": rid, "question_id": "q9", "answer": "x"}, - }) - assert response["error"]["code"] == 4002 - - server.handle_request({ - "id": "ok", "method": "clarify.respond", - "params": {"request_id": rid, "question_id": "q0", "answer": "fine"}, - }) - thread.join(timeout=5) + # Deadline: locked answers survive, timed_out flagged, one request.cancel. + original_timeout = server._clarify_timeout_seconds + try: + thread, box, req = _start_batch_clarify(server, buf, ["q0", "q1"], timeout=1.5) + locked = server.handle_request({"id": "b1", "method": "clarify.lock", + "params": {"request_id": req.id, "question_id": "q0", "answer": "kept"}}) + assert locked["result"]["status"] == "ok" + thread.join(timeout=5) + finally: + server._clarify_timeout_seconds = original_timeout + assert json.loads(box["answer"]) == {"answers": {"q0": "kept"}, "timed_out": True} + cancels = [f for f in _frames(buf) if f.get("method") == "event" and f["params"]["type"] == "request.cancel"] + assert [c["params"]["payload"]["id"] for c in cancels] == [req.id] -def test_clarify_batch_timeout_keeps_locked_answers(capture): - """Locked answers survive the deadline: the tool sees the partials plus - timed_out instead of an empty string.""" +def test_clarify_batch_cancel_all_is_a_response_without_answers(capture): server, buf = capture - thread, box, rid = _drain_batch_block(server, ["q0", "q1"], timeout=1) - - server.handle_request({ - "id": "a1", "method": "clarify.respond", - "params": {"request_id": rid, "question_id": "q0", "answer": "kept"}, - }) - - thread.join(timeout=10) - assert not thread.is_alive() - result = json.loads(box["answer"]) - assert result == {"answers": {"q0": "kept"}, "timed_out": True} - # The expire notification still fires for the un-finished batch. - messages = [json.loads(line) for line in buf.getvalue().splitlines()] - assert any(m["params"]["type"] == "clarify.expire" for m in messages) - - -def test_clarify_batch_cancel_all_returns_empty(server): - """A respond without question_id cancels the whole batch (Esc path).""" - thread, box, rid = _drain_batch_block(server, ["q0", "q1"]) - - server.handle_request({ - "id": "cancel", "method": "clarify.respond", - "params": {"request_id": rid, "answer": ""}, - }) - + thread, box, req = _start_batch_clarify(server, buf, ["q0", "q1"]) + server.dispatch({"jsonrpc": "2.0", "id": req.id, "result": {}}) thread.join(timeout=5) assert box["answer"] == "" -def test_clarify_batch_late_question_respond_is_idempotent(server): - response = server.handle_request({ - "id": "late", "method": "clarify.respond", - "params": {"request_id": "gone", "question_id": "q0", "answer": "x"}, - }) - assert response["result"] == {"status": "expired"} - - -def test_clarify_batch_state_cleared_after_resolution(server): - thread, box, rid = _drain_batch_block(server, ["q0"]) - server.handle_request({ - "id": "a", "method": "clarify.respond", - "params": {"request_id": rid, "question_id": "q0", "answer": "x"}, - }) - thread.join(timeout=5) - with server._prompt_lock: - assert rid not in server._batch_clarify - assert rid not in server._pending - - -def test_clarify_block_helper_builds_batch_payload(capture): - """_clarify_block forwards only wire fields (qid/question/choices/ - multi_select) — the tool-side normalized entries carry extra keys the - renderer must not see.""" +def test_clear_pending_cancels_only_that_session(capture): + from tui_gateway import server_requests server, buf = capture - normalized = [ - { - "qid": "q0", "id": "approach", "question": "Which?", - "choices": ["a (Recommended)", "b"], "choices_offered": ["a", "b"], - "multi_select": False, - }, - ] - - box = {} - - def run(): - box["answer"] = server._clarify_block("s1", "", None, questions=normalized) - - thread = threading.Thread(target=run, daemon=True) - thread.start() + a = threading.Thread(target=lambda: server_requests.send("sudo", "sid-a", {}, timeout=None), daemon=True) + b = threading.Thread(target=lambda: server_requests.send("sudo", "sid-b", {}, timeout=None), daemon=True) + a.start(); b.start() deadline = time.monotonic() + 2 - rid = None - while time.monotonic() < deadline and rid is None: - with server._prompt_lock: - rid = next(iter(server._batch_clarify), None) + while time.monotonic() < deadline and len(server_requests._open) < 2: time.sleep(0.01) - assert rid - - server.handle_request({ - "id": "a", "method": "clarify.respond", - "params": {"request_id": rid, "question_id": "q0", "answer": "a"}, - }) - thread.join(timeout=5) - - messages = [json.loads(line) for line in buf.getvalue().splitlines()] - request = messages[0]["params"] - assert request["type"] == "clarify.request" - sent = request["payload"]["questions"][0] - assert set(sent) == {"qid", "question", "choices", "multi_select"} - assert "id" not in sent and "choices_offered" not in sent + assert server._session_pending_kind("sid-a") == "sudo" + server._clear_pending("sid-a") + a.join(timeout=2) + assert not a.is_alive() and b.is_alive() + assert server._session_pending_kind("sid-a") == "" and server._session_pending_kind("sid-b") == "sudo" + server._clear_pending() + b.join(timeout=2) + assert not b.is_alive() + reasons = [f["params"]["payload"]["reason"] for f in _frames(buf) if f.get("method") == "event"] + assert reasons == ["interrupted", "shutdown"] def test_approval_pending_replays_unresolved_requests(server, monkeypatch): @@ -701,16 +555,6 @@ def test_approval_respond_4001_when_nothing_resolves(server, monkeypatch): assert response["error"]["code"] == 4001 -def test_clear_pending(server): - ev = threading.Event() - # _pending values are (sid, Event) tuples - server._pending["r1"] = ("sid-x", ev) - server._clear_pending() - - assert ev.is_set() - assert server._answers["r1"] == "" - - # ── Session lookup ─────────────────────────────────────────────────── -
tests/tui_gateway/test_review_summary_callback.py@@ -43,8 +43,7 @@ def server(): # _enter_buffered_busy. Clearing the per-session dicts gives the # next test a clean slate. mod._sessions.clear() - mod._pending.clear() - mod._answers.clear() + __import__("tui_gateway.server_requests", fromlist=["x"]).reset_for_tests() def test_init_session_attaches_background_review_callback(server, monkeypatch): -
tests/tui_gateway/test_session_control.py@@ -43,8 +43,7 @@ def server(hermes_home, monkeypatch): monkeypatch.setattr(mod, "_cfg_path", None) yield mod mod._sessions.clear() - mod._pending.clear() - mod._answers.clear() + __import__("tui_gateway.server_requests", fromlist=["x"]).reset_for_tests() @pytest.fixture() -
tests/tui_gateway/test_session_profile_db.py@@ -64,8 +64,7 @@ def server(hermes_home): mod._methods.clear() mod._methods.update(methods) mod._sessions.clear() - mod._pending.clear() - mod._answers.clear() + __import__("tui_gateway.server_requests", fromlist=["x"]).reset_for_tests() mod._db = None -
tests/tui_gateway/test_stranded_session_adoption.py@@ -183,8 +183,7 @@ def gateway(tmp_path, monkeypatch): mod._methods.clear() mod._methods.update(methods) mod._sessions.clear() - mod._pending.clear() - mod._answers.clear() + __import__("tui_gateway.server_requests", fromlist=["x"]).reset_for_tests() mod._db = None default_db.close() -
tests/tui_gateway/test_subagent_child_mirror.py@@ -36,8 +36,7 @@ def server(): yield mod mod._sessions.clear() - mod._pending.clear() - mod._answers.clear() + __import__("tui_gateway.server_requests", fromlist=["x"]).reset_for_tests() mod._child_mirrors.clear() mod._active_child_runs.clear() -
tests/tui_gateway/test_tour_bridge_fail_fast.py@@ -1,9 +1,9 @@ -"""A desktop client that cannot answer ``tour.request`` must not cost a full +"""A desktop client that cannot answer the ``tour`` server request must not cost a full bridge timeout per call. The renderer's handler ships in the desktop bundle; the tool is offered by the backend. An app build older than the tour tool has no branch for the event, so -nothing ever calls ``tour.respond`` and the agent blocks for the whole deadline +nothing ever answers the request and the agent blocks for the whole deadline — once per action the model tries. See tui_gateway.server._tour_request. """ @@ -23,7 +23,7 @@ def session(monkeypatch): @pytest.fixture def bridge(monkeypatch): - """Record every _block call and serve canned answers.""" + """Record every ``_ask`` (server request) call and serve canned answers.""" calls = [] def fake_block(event, sid, payload, timeout=None, **_kw): @@ -33,7 +33,7 @@ def fake_block(event, sid, payload, timeout=None, **_kw): fake_block.answers = [] fake_block.calls = calls - monkeypatch.setattr(server, "_block", fake_block) + monkeypatch.setattr(server, "_ask", fake_block) return fake_block @@ -41,7 +41,7 @@ def test_first_action_is_probed_on_a_short_deadline(session, bridge): bridge.answers = [json.dumps({"success": True})] server._tour_request("s1", {"action": "targets"}) - assert bridge.calls[0]["event"] == "tour.request" + assert bridge.calls[0]["event"] == "tour" assert bridge.calls[0]["timeout"] == server._TOUR_PROBE_TIMEOUT_S assert server._TOUR_PROBE_TIMEOUT_S < server._TOUR_TIMEOUT_S -
tests/tui_gateway/test_tui_gateway_server.py@@ -496,15 +496,17 @@ def test_compute_host_turn_end_updates_metadata_mirror(monkeypatch): server._sessions.pop("iso-sid", None) -def test_compute_host_clarify_snapshot_replays_and_proxies_batch_answers(monkeypatch): - """A host-owned clarify survives activation and receives its UI answers.""" +def test_compute_host_open_request_survives_activation_and_proxies_locks_and_responses(monkeypatch): + """A host-owned server request (batch clarify) is mirrored by the parent so `open_requests` replays it; + `clarify.lock` and the client's response frame are relayed to the child that owns the wait.""" class _Supervisor: def __init__(self): self.responses = [] def respond(self, sid, params, *, timeout=15.0): self.responses.append((sid, dict(params), timeout)) - remaining = ["q1"] if params.get("question_id") == "q0" else [] + lock = params.get("lock") or {} + remaining = ["q1"] if lock.get("question_id") == "q0" else [] return {"type": "respond.ack", "response": {"result": {"status": "ok", "remaining": remaining}}} sid = "host-clarify" @@ -515,53 +517,25 @@ def respond(self, sid, params, *, timeout=15.0): monkeypatch.setattr(server, "_get_compute_host_supervisor", lambda _cfg=None: supervisor) monkeypatch.setattr(server, "write_json", lambda _message: True) + questions = [{"qid": "q0", "question": "First?", "choices": ["a"]}, {"qid": "q1", "question": "Second?", "choices": ["b"]}] try: - server._relay_compute_host_rpc( - { - "jsonrpc": "2.0", - "method": "event", - "params": { - "type": "clarify.request", - "session_id": sid, - "payload": { - "request_id": "host-request", - "questions": [ - {"qid": "q0", "question": "First?", "choices": ["a"]}, - {"qid": "q1", "question": "Second?", "choices": ["b"]}, - ], - }, - }, - } - ) + server._relay_compute_host_rpc({"jsonrpc": "2.0", "id": "srq-host", "method": "clarify", + "params": {"session_id": sid, "questions": questions}}) activated = server._live_session_payload(sid, session) - assert activated["pending_clarify"]["request_id"] == "host-request" - - response = server.handle_request( - { - "id": "clarify-q0", - "method": "clarify.respond", - "params": {"request_id": "host-request", "question_id": "q0", "answer": "a"}, - } - ) + assert activated["open_requests"] == [{"id": "srq-host", "method": "clarify", + "params": {"session_id": sid, "questions": questions}}] + response = server.handle_request({"id": "lock-q0", "method": "clarify.lock", + "params": {"request_id": "srq-host", "question_id": "q0", "answer": "a"}}) assert response["result"] == {"status": "ok", "remaining": ["q1"]} - assert supervisor.responses == [ - (sid, {"request_id": "host-request", "question_id": "q0", "answer": "a"}, 15.0) - ] - replayed = server._live_session_payload(sid, session)["pending_clarify"] - assert replayed["answers"] == {"q0": "a"} + assert supervisor.responses == [(sid, {"lock": {"request_id": "srq-host", "question_id": "q0", "answer": "a"}}, 15.0)] + assert server._live_session_payload(sid, session)["open_requests"][0]["params"]["answers"] == {"q0": "a"} - final_response = server.handle_request( - { - "id": "clarify-q1", - "method": "clarify.respond", - "params": {"request_id": "host-request", "question_id": "q1", "answer": "b"}, - } - ) - - assert final_response["result"] == {"status": "ok", "remaining": []} - assert "pending_clarify" not in server._live_session_payload(sid, session) + # The client's response frame (cancel-all) is relayed to the child and clears the mirror. + assert server.dispatch({"jsonrpc": "2.0", "id": "srq-host", "result": {}}) is None + assert supervisor.responses[-1] == (sid, {"frame": {"jsonrpc": "2.0", "id": "srq-host", "result": {}}}, 15.0) + assert "open_requests" not in server._live_session_payload(sid, session) finally: server._sessions.pop(sid, None) @@ -13585,10 +13559,20 @@ def replace_messages( # --------------------------------------------------------------------------- +def _open_request(sid, method="clarify"): + from tui_gateway import server_requests + req = server_requests.ServerRequest(sid, method, {}) + with server_requests._lock: + server_requests._open[req.id] = req + return req + + def test_interrupt_only_clears_own_session_pending(): - """session.interrupt on session A must NOT release pending prompts - that belong to session B.""" + """session.interrupt on session A withdraws A's open server→client requests (a request.cancel each) + and must NOT touch session B's — otherwise B's clarify/sudo/secret prompt silently resolves as if + the user cancelled it.""" import types + from tui_gateway import server_requests session_a = _session() session_a["agent"] = types.SimpleNamespace(interrupt=lambda: None) @@ -13596,71 +13580,21 @@ def test_interrupt_only_clears_own_session_pending(): session_b["agent"] = types.SimpleNamespace(interrupt=lambda: None) server._sessions["sid_a"] = session_a server._sessions["sid_b"] = session_b + req_a1, req_a2, req_b = _open_request("sid_a"), _open_request("sid_a", "sudo"), _open_request("sid_b") try: - # Simulate pending prompts on both sessions (what _block creates - # while a clarify/sudo/secret request is outstanding). - ev_a = threading.Event() - ev_b = threading.Event() - server._pending["rid-a"] = ("sid_a", ev_a) - server._pending["rid-b"] = ("sid_b", ev_b) - server._answers.clear() - - # Interrupt session A. - resp = server.handle_request( - { - "id": "1", - "method": "session.interrupt", - "params": {"session_id": "sid_a"}, - } - ) + resp = server.handle_request({"id": "1", "method": "session.interrupt", "params": {"session_id": "sid_a"}}) assert resp.get("result"), f"got error: {resp.get('error')}" - # Session A's pending must be released to empty. - assert ev_a.is_set(), "sid_a pending Event should be set after interrupt" - assert server._answers.get("rid-a") == "" - - # Session B's pending MUST remain untouched — no cross-session blast. - assert not ev_b.is_set(), ( - "CRITICAL: session.interrupt on sid_a released a pending prompt " - "belonging to sid_b — other sessions' clarify/sudo/secret " - "prompts are being silently cancelled" - ) - assert "rid-b" not in server._answers + assert req_a1.event.is_set() and req_a2.event.is_set() and not req_a1.answered + assert not req_b.event.is_set(), ( + "CRITICAL: session.interrupt on sid_a released a prompt belonging to sid_b") + assert server_requests.open_requests("sid_a") == [] + assert [r["id"] for r in server_requests.open_requests("sid_b")] == [req_b.id] finally: server._sessions.pop("sid_a", None) server._sessions.pop("sid_b", None) - server._pending.pop("rid-a", None) - server._pending.pop("rid-b", None) - server._answers.pop("rid-a", None) - server._answers.pop("rid-b", None) - - -def test_interrupt_clears_multiple_own_pending(): - """When a single session has multiple pending prompts (uncommon but - possible via nested tool calls), interrupt must release all of them.""" - import types - - sess = _session() - sess["agent"] = types.SimpleNamespace(interrupt=lambda: None) - server._sessions["sid"] = sess - - try: - ev1, ev2 = threading.Event(), threading.Event() - server._pending["r1"] = ("sid", ev1) - server._pending["r2"] = ("sid", ev2) - - resp = server.handle_request( - {"id": "1", "method": "session.interrupt", "params": {"session_id": "sid"}} - ) - assert resp.get("result") - assert ev1.is_set() and ev2.is_set() - assert server._answers.get("r1") == "" and server._answers.get("r2") == "" - finally: - server._sessions.pop("sid", None) - for key in ("r1", "r2"): - server._pending.pop(key, None) - server._answers.pop(key, None) + server_requests.reset_for_tests() def test_run_prompt_submit_registers_turn_thread_for_interrupt(monkeypatch): @@ -14295,39 +14229,16 @@ def is_alive(self): def test_clear_pending_without_sid_clears_all(): - """_clear_pending(None) is the shutdown path — must still release - every pending prompt regardless of owning session.""" - ev1, ev2, ev3 = threading.Event(), threading.Event(), threading.Event() - server._pending["a"] = ("sid_x", ev1) - server._pending["b"] = ("sid_y", ev2) - server._pending["c"] = ("sid_z", ev3) + """_clear_pending(None) is the process-exit path — every open request is withdrawn, and a response for + a withdrawn id is dropped quietly (no error frame back to the client).""" + from tui_gateway import server_requests + reqs = [_open_request("sid-x"), _open_request("sid-y", "sudo")] try: server._clear_pending(None) - assert ev1.is_set() and ev2.is_set() and ev3.is_set() - finally: - for key in ("a", "b", "c"): - server._pending.pop(key, None) - server._answers.pop(key, None) - - -def test_respond_unpacks_sid_tuple_correctly(): - """After the (sid, Event) tuple change, _respond must still work.""" - ev = threading.Event() - server._pending["rid-x"] = ("sid_x", ev) - try: - resp = server.handle_request( - { - "id": "1", - "method": "clarify.respond", - "params": {"request_id": "rid-x", "answer": "the answer"}, - } - ) - assert resp.get("result") - assert ev.is_set() - assert server._answers.get("rid-x") == "the answer" + assert all(r.event.is_set() and not r.answered for r in reqs) + assert server.dispatch({"jsonrpc": "2.0", "id": reqs[0].id, "result": {"answer": "late"}}) is None finally: - server._pending.pop("rid-x", None) - server._answers.pop("rid-x", None) + server_requests.reset_for_tests() # --------------------------------------------------------------------------- @@ -20697,49 +20608,44 @@ def fake_listen(should_stop, capture=False, on_trigger=None, **_kw): assert not listened.is_set() -def test_clarify_callback_uses_configured_timeout(monkeypatch): - """The TUI/desktop clarify bridge honors the canonical clarify timeout - (via _clarify_timeout_seconds) instead of the hardcoded _block default.""" +def _capture_server_request(monkeypatch, result): + """Stub the server-request send and capture (method, sid, params, timeout).""" + from tui_gateway import server_requests captured = {} - monkeypatch.setattr(server, "_clarify_timeout_seconds", lambda: 42) + def fake_send(method, sid, params, *, timeout, qids=None): + captured.update(method=method, sid=sid, params=params, timeout=timeout, qids=qids) + return result - def fake_block(event, sid, payload, timeout=300): - captured.update(event=event, sid=sid, payload=payload, timeout=timeout) - return "answer" + monkeypatch.setattr(server_requests, "send", fake_send) + return captured - monkeypatch.setattr(server, "_block", fake_block) + +def test_clarify_callback_uses_configured_timeout(monkeypatch): + """The TUI/desktop clarify bridge sends a ``clarify`` server request with the canonical clarify timeout + (via _clarify_timeout_seconds), and returns the response's ``answer``.""" + monkeypatch.setattr(server, "_clarify_timeout_seconds", lambda: 42) + captured = _capture_server_request(monkeypatch, {"answer": "answer"}) result = server._agent_cbs("sid-1")["clarify_callback"]("Pick one", ["a", "b"]) assert result == "answer" - assert captured["event"] == "clarify.request" + assert captured["method"] == "clarify" and captured["sid"] == "sid-1" assert captured["timeout"] == 42 - assert captured["payload"] == {"question": "Pick one", "choices": ["a", "b"]} + assert captured["params"] == {"question": "Pick one", "choices": ["a", "b"]} def test_clarify_callback_multi_select_hint(monkeypatch): - """multi_select=True adds the hint to the payload; the single-select - payload shape stays byte-identical to the pre-multi-select protocol - (older renderers must never see the extra field).""" - captured = {} - - def fake_block(event, sid, payload, timeout=300): - captured.update(payload=payload) - return "answer" - - monkeypatch.setattr(server, "_block", fake_block) + """multi_select=True adds the hint to the params; the single-select shape stays byte-identical to the + pre-multi-select protocol (older renderers must never see the extra field).""" + captured = _capture_server_request(monkeypatch, {"answer": "answer"}) cb = server._agent_cbs("sid-1")["clarify_callback"] cb("Pick many", ["a", "b"], multi_select=True) - assert captured["payload"] == { - "question": "Pick many", - "choices": ["a", "b"], - "multi_select": True, - } + assert captured["params"] == {"question": "Pick many", "choices": ["a", "b"], "multi_select": True} cb("Pick one", ["a", "b"], multi_select=False) - assert captured["payload"] == {"question": "Pick one", "choices": ["a", "b"]} + assert captured["params"] == {"question": "Pick one", "choices": ["a", "b"]} @pytest.mark.parametrize( @@ -20747,8 +20653,8 @@ def fake_block(event, sid, payload, timeout=300): [(0, None), (-1, None), (42, 42)], ) def test_clarify_timeout_seconds_maps_non_positive_to_unlimited(monkeypatch, configured, expected): - """A ``<= 0`` clarify timeout means unlimited and reaches _block as None - (ev.wait(None) waits forever) rather than an immediate ev.wait(0) skip.""" + """A ``<= 0`` clarify timeout means unlimited and reaches the server request as None + (wait(None) waits forever) rather than an immediate wait(0) skip.""" monkeypatch.setattr("tools.clarify_gateway.get_clarify_timeout", lambda: configured) assert server._clarify_timeout_seconds() == expected -
tests/tui_gateway/test_undo_command.py@@ -55,8 +55,7 @@ def server(hermes_home): mod._methods.clear() mod._methods.update(methods) mod._sessions.clear() - mod._pending.clear() - mod._answers.clear() + __import__("tui_gateway.server_requests", fromlist=["x"]).reset_for_tests() mod._db = None -
tools/approval.py@@ -177,6 +177,17 @@ def list_gateway_approvals(session_key: str) -> list[dict]: return [dict(entry.data) for entry in _gateway_queues.get(session_key, [])] +def register_gateway_settle(session_key: str, request_id: str, settle) -> bool: + """Attach ``settle(reason)`` to one pending approval; it runs once when that wait ends by any path. + False when the request is no longer pending (the surface should withdraw its prompt itself).""" + with _lock: + for entry in _gateway_queues.get(session_key, []): + if entry.data.get("request_id") == request_id: + entry.settle = settle + return True + return False + + def ack_gateway_approval(session_key: str, request_id: str) -> bool: """Record that a client received a particular pending approval request.""" with _lock: -
tools/approval_gateway_wait.py@@ -24,13 +24,16 @@ class _ApprovalEntry: """One pending dangerous-command approval inside a gateway session.""" - __slots__ = ("event", "data", "result", "reason", "acknowledged") + __slots__ = ("event", "data", "result", "reason", "acknowledged", "settle") def __init__(self, data: dict): self.event = threading.Event() self.data = dict(data) self.data.setdefault("request_id", uuid.uuid4().hex) self.acknowledged = False + # Surface hook run once when the wait ends by ANY path (answer, timeout, interrupt, /approve from + # another client): the tui_gateway withdraws its open server→client request through it. + self.settle = None self.result: str | None = None # "once"|"session"|"always"|"deny" # Free-text reason from ``/deny <reason>`` so the agent can adapt, not just hear "denied". self.reason: str | None = None @@ -139,13 +142,19 @@ def _await_gateway_decision(session_key: str, notify_cb, approval_data: dict, *, with _approval._lock: _approval._gateway_queues.setdefault(session_key, []).append(entry) - def _drop_entry() -> None: + def _drop_entry(reason: str) -> None: with _approval._lock: queue = _approval._gateway_queues.get(session_key, []) if entry in queue: queue.remove(entry) if not queue: _approval._gateway_queues.pop(session_key, None) + settle, entry.settle = entry.settle, None + if settle is not None: + try: + settle(reason) + except Exception: + logger.debug("approval settle hook failed", exc_info=True) # Plugins hear about the request before the gateway does (real-time observers). _ctx._fire_approval_hook("pre_approval_request", **payload) @@ -154,7 +163,7 @@ def _drop_entry() -> None: notify_cb(dict(entry.data)) except Exception as exc: logger.warning("Gateway approval notify failed: %s", exc) - _drop_entry() + _drop_entry("notify_failed") _ctx._fire_approval_hook("post_approval_response", **payload, choice="notify_failed") return {"resolved": False, "choice": None, "notify_failed": True} @@ -163,5 +172,5 @@ def _drop_entry() -> None: if state == "interrupted": entry.result = "deny" entry.event.set() - _drop_entry() + _drop_entry("answered" if state == "set" else state) return _finish(payload, state != "timeout", entry.result, entry.reason) -
tui_gateway/AGENTS.md@@ -18,12 +18,20 @@ Never move agent behaviour into the renderer. ## Transport -Newline-delimited JSON-RPC over stdio: requests from Ink, events from Python. `tui_gateway/server.py` +Newline-delimited JSON-RPC over stdio, peer-to-peer: client→server method calls, server→client +**requests** (the agent asking the user something: `approval`, `clarify`, `sudo`, `secret`, `vault.*`, +`mcp.setup`, the desktop read/act bridges) and server→client `event` notifications. `tui_gateway/server.py` is the facade with the method/event catalog; methods live in `methods_*.py` siblings (`methods_config`, `methods_complete`, `methods_browser`, `methods_bot_relay`, ...), event publishing in -`event_publisher.py` / `event_replay.py`. Desktop reaches the same server over WebSocket via -`apps/shared` (`JsonRpcGatewayClient`). New RPC = a new `methods_<topic>.py` or an entry in an -existing topical sibling, registered in the table — no `if method == ...` chain (root shape rules). +`event_publisher.py` / `event_replay.py`, server→client requests in `server_requests.py` (`send()` blocks +the agent thread until the response frame with the same `srq-<n>` id arrives; `cancel*` withdraws with a +`request.cancel` event; `open_requests(sid)` is what `session.resume` / `session.events.since` replay so a +reconnecting client re-renders the still-open questions). Desktop reaches the same server over WebSocket +via `apps/shared` (`JsonRpcGatewayClient`, `onRequest`). New RPC = a new `methods_<topic>.py` or an entry +in an existing topical sibling, registered in the table — no `if method == ...` chain (root shape rules). +New question for the user = `_ask("<method>", sid, params, timeout)` in the emitter, a handler in +`apps/desktop/.../gateway-event/server-requests.ts` and `ui-tui/src/app/createServerRequestHandler.ts`, +and the method in `ServerRequestMap` + `apps/shared/src/gateway-events.json`. New event = a new key in `apps/shared/src/gateway-events.ts::GatewayEventMap` + `BACKEND_EVENT_NAMES` AND `apps/shared/src/gateway-events.json`; `tests/tui_gateway/test_gateway_event_contract.py` (emitter side) and `apps/shared/src/gateway-events.test.ts` (type side) both fail when either drifts. @@ -34,8 +42,8 @@ side) and `apps/shared/src/gateway-events.test.ts` (type side) both fail when ei |---|---|---| | Chat streaming | `app.tsx` + `messageLine.tsx` | `prompt.submit` → `message.delta` / `message.complete` | | Tool activity | `thinking.tsx` | `tool.start` / `tool.generating` / `tool.complete` | -| Approvals | `prompts.tsx` | `approval.request` → `approval.respond` | -| Clarify / sudo / secret | `prompts.tsx`, `maskedPrompt.tsx` | `clarify.respond`, `sudo.respond`, `secret.respond` | +| Approvals | `prompts.tsx` | server→client request `approval` → response `{choice}` | +| Clarify / sudo / secret | `prompts.tsx`, `maskedPrompt.tsx` | server→client requests `clarify` / `sudo` / `secret` (`server_requests.py`) | | Session picker | `sessionPicker.tsx` | `session.list` / `session.resume` | | Slash commands | local handler + fallthrough | `slash.exec` → `_SlashWorker`; `command.dispatch` | | Completions | `useCompletion` hook | `complete.slash`, `complete.path` | -
tui_gateway/agent_callbacks.py@@ -79,11 +79,11 @@ def _mirror_subagent_to_child(event_type: str, payload: dict) -> None: def _agent_cbs(sid: str) -> dict: - def _read_block(event: str, timeout: int): - # read_terminal / read_preview (desktop GUI): blocking bridge like clarify; the preview + def _read_block(method: str, timeout: int): + # read_terminal / read_preview (desktop GUI): server request like clarify; the preview # read gets longer since a URL tab extracts text from a live page. - return lambda start=None, count=None: _block( - event, sid, {k: v for k, v in (("start", start), ("count", count)) if v is not None}, + return lambda start=None, count=None: _ask( + method, sid, {k: v for k, v in (("start", start), ("count", count)) if v is not None}, timeout=timeout) callbacks = { @@ -105,17 +105,17 @@ def _read_block(event: str, timeout: int): "notice_clear_callback": lambda key: _emit("notification.clear", sid, {"key": key}), "clarify_callback": lambda q, c, multi_select=False, questions=None: ( _clarify_block(sid, q, c, multi_select=multi_select, questions=questions)), - "read_terminal_callback": _read_block("terminal.read.request", 30), - "read_preview_callback": _read_block("preview.read.request", 45), + "read_terminal_callback": _read_block("terminal.read", 30), + "read_preview_callback": _read_block("preview.read", 45), # drive_preview / annotate_preview (desktop GUI): same budget as the preview read it ends with. - "drive_preview_callback": lambda payload: _block("preview.act.request", sid, dict(payload), timeout=45), + "drive_preview_callback": lambda payload: _ask("preview.act", sid, dict(payload), timeout=45), # read_window_below (desktop GUI): main process enumerates native windows. - "read_window_below_callback": lambda: _block("window.read.request", sid, {}, timeout=30), + "read_window_below_callback": lambda: _ask("window.read", sid, {}, timeout=30), # setup_mcp (desktop GUI): consent card + install/enable/OAuth; long timeout on purpose - # (typing an API key, browser OAuth) and, like clarify, a late answer is tolerated. - "setup_mcp_callback": lambda server, action, reason: _block( - "mcp.setup.request", sid, {"server": server, "action": action, "reason": reason}, timeout=600), - # tour (desktop GUI): renderer drives driver.js and answers tour.respond. + # (typing an API key, browser OAuth). + "setup_mcp_callback": lambda server, action, reason: _ask( + "mcp.setup", sid, {"server": server, "action": action, "reason": reason}, timeout=600), + # tour (desktop GUI): renderer drives driver.js and answers the ``tour`` request. "tour_callback": lambda payload: _tour_request(sid, payload)} # Interim assistant commentary (text alongside tool calls), gated on display.interim_assistant_ @@ -162,13 +162,13 @@ def _wire_callbacks(sid: str): def secret_cb(env_var, prompt, metadata=None): pl = {"prompt": prompt, "env_var": env_var, **({"metadata": metadata} if metadata else {})} - val = _block("secret.request", sid, pl) + val = _ask("secret", sid, pl) if not val: return {"success": True, "stored_as": env_var, "validated": False, "skipped": True, "message": "skipped"} from hermes_cli.config import save_env_value_secure return {**save_env_value_secure(env_var, val), "skipped": False, "message": "ok"} - set_sudo_password_callback(lambda: _block("sudo.request", sid, {}, timeout=120)) + set_sudo_password_callback(lambda: _ask("sudo", sid, {}, timeout=120)) set_project_workspace_callback(_apply_project_workspace) set_secret_capture_callback(secret_cb) # External password-manager unlock: the renderer shows a masked master-password card; the @@ -176,12 +176,12 @@ def secret_cb(env_var, prompt, metadata=None): from agent.vault_backends.unlock import (set_code_prompt_callback, set_current_session_id, set_save_login_prompt_callback, set_unlock_prompt_callback) set_current_session_id(sid) # an unlock made on this turn belongs to this session (released with it) - set_unlock_prompt_callback(lambda backend, display_name: _block( - "vault.unlock.request", sid, {"backend": backend, "display_name": display_name}, timeout=120)) + set_unlock_prompt_callback(lambda backend, display_name: _ask( + "vault.unlock_prompt", sid, {"backend": backend, "display_name": display_name}, timeout=120)) def save_login_cb(origin, site): # The renderer shows identifier + masked password; the JSON answer goes straight to the vault store. - raw = _block("vault.save_login.request", sid, {"origin": origin, "site": site}, timeout=180) + raw = _ask("vault.save_login", sid, {"origin": origin, "site": site}, timeout=180) try: data = json.loads(raw) if raw else None except ValueError: @@ -189,8 +189,8 @@ def save_login_cb(origin, site): return data if isinstance(data, dict) and data.get("password") else None set_save_login_prompt_callback(save_login_cb) - set_code_prompt_callback(lambda site, hint: _block( - "vault.code.request", sid, {"site": site, "hint": hint}, timeout=180)) + set_code_prompt_callback(lambda site, hint: _ask( + "vault.code", sid, {"site": site, "hint": hint}, timeout=180)) def _available_personalities(cfg: dict | None = None) -> dict: -
tui_gateway/compute_host.py@@ -190,7 +190,9 @@ def body(server: Any, sid: str, request_id: Any) -> None: self._guarded(frame, "interrupt.ack", body, applied=False) def _handle_respond(self, frame: dict[str, Any]) -> None: - """Resolve an interactive request in the host-owned pending registry.""" + """Resolve a server→client request this host owns: ``params.frame`` is the client's JSON-RPC response + frame relayed by the parent; ``params.lock`` is one batch-clarify lock (answered with ``clarify.lock``'s + result so the parent can ack the client).""" def body(server: Any, sid: str, request_id: Any) -> None: params = frame.get("params") error = ("session not found" if sid not in server._sessions @@ -198,7 +200,13 @@ def body(server: Any, sid: str, request_id: Any) -> None: if error: self._reply("respond.error", sid, request_id, message=error) return - response = server._methods["clarify.respond"](request_id, params) + from tui_gateway import server_requests + if isinstance(params.get("lock"), dict): + response = server._methods["clarify.lock"](request_id, params["lock"]) + else: + response_frame = params.get("frame") if isinstance(params.get("frame"), dict) else params + resolved = server_requests.resolve_response(response_frame) + response = {"jsonrpc": "2.0", "id": request_id, "result": {"status": "ok" if resolved else "expired"}} self._reply("respond.ack", sid, request_id, response=response) self._guarded(frame, "respond.error", body) -
tui_gateway/compute_host_bridge.py@@ -90,7 +90,8 @@ def _compute_host_adopt_frame_meta(session: dict, frame: dict) -> None: def _relay_compute_host_rpc(message: dict) -> bool: - """Relay host events while retaining the clarify snapshot needed on resume.""" + """Relay host frames to the client while mirroring the server→client request the host has open, so a + reconnecting client gets it back through ``open_requests``.""" params = message.get("params") if isinstance(message, dict) else None if isinstance(message, dict) and message.get("method") == "compute_host.activity": if isinstance(params, dict): @@ -101,17 +102,20 @@ def _relay_compute_host_rpc(message: dict) -> bool: and session.get("_compute_host_turn_id") == params["turn_id"]): session["_compute_host_activity_ns"] = params.get("activity_ns") return True # Internal observation, not a client event or replay entry. - kind = params.get("type") if isinstance(params, dict) else None - if kind in {"clarify.request", "clarify.expire"}: + if isinstance(message, dict) and isinstance(message.get("id"), str) and message.get("method") not in (None, "event"): + # A server request minted by the child: remember it against its session until it is answered/withdrawn. + session = _sessions.get(str((params or {}).get("session_id") or "")) if isinstance(params, dict) else None + if session is not None: + with _history_lock(session): + session["_compute_host_open_request"] = { + "id": message["id"], "method": message["method"], "params": dict(params)} + elif isinstance(params, dict) and params.get("type") == "request.cancel": session = _sessions.get(str(params.get("session_id") or "")) payload = params.get("payload") - request_id = payload.get("request_id") if isinstance(payload, dict) else None - if session is not None and request_id: + if session is not None and isinstance(payload, dict): with _history_lock(session): - if kind == "clarify.request": - session["_compute_host_pending_clarify"] = dict(payload) - elif _pending_clarify_matches(session, request_id): - session.pop("_compute_host_pending_clarify", None) + if _open_request_matches(session, payload.get("id")): + session.pop("_compute_host_open_request", None) return write_json(message) @@ -119,62 +123,65 @@ def _history_lock(session: dict): return session.get("history_lock", threading.Lock()) -def _pending_clarify_matches(session: dict, request_id) -> bool: - """Whether ``session``'s mirrored pending clarify is ``request_id``. Caller holds - history_lock.""" - pending = session.get("_compute_host_pending_clarify") - return isinstance(pending, dict) and pending.get("request_id") == request_id +def _open_request_matches(session: dict, request_id) -> bool: + """Whether ``session``'s mirrored open request is ``request_id``. Caller holds history_lock.""" + mirrored = session.get("_compute_host_open_request") + return isinstance(mirrored, dict) and mirrored.get("id") == request_id -def _compute_host_clarify_session(request_id: str) -> tuple[str, dict] | None: - """Find the parent mirror for one host-owned clarify request.""" +def _compute_host_request_session(request_id: str) -> tuple[str, dict] | None: + """Find the parent mirror for one host-owned server request.""" for sid, session in list(_sessions.items()) if request_id else (): with _history_lock(session): - if _pending_clarify_matches(session, request_id): + if _open_request_matches(session, request_id): return sid, session return None -def _update_compute_host_clarify_snapshot(sid: str, session: dict, params: dict, result: dict) -> None: - """Keep reconnect snapshots accurate while a batch clarify is answered.""" - request_id = str(params.get("request_id") or "") - question_id = str(params.get("question_id") or "") +def _relay_compute_host_response(frame: dict) -> bool: + """Forward a client's response frame to the compute-host child that owns the request. False when no + child owns that id.""" + located = _compute_host_request_session(str(frame.get("id") or "")) + if located is None or not _session_uses_compute_host(located[1]): + return False + sid, session = located with _history_lock(session): - if not _pending_clarify_matches(session, request_id): - return - pending = session["_compute_host_pending_clarify"] - if result.get("status") == "expired" or not result.get("remaining") and not question_id: - session.pop("_compute_host_pending_clarify", None) - elif question_id and isinstance(result.get("remaining"), list): - pending["answers"] = {**(pending.get("answers") or {}), - question_id: str(params.get("answer") or "")} - if not result["remaining"]: - session.pop("_compute_host_pending_clarify", None) - - -def _respond_compute_host_clarify(rid: str, params: dict) -> dict | None: - """Proxy a clarify answer into the process that owns its pending Event.""" - located = _compute_host_clarify_session(str(params.get("request_id") or "")) + session.pop("_compute_host_open_request", None) + try: + _get_compute_host_supervisor().respond(sid, {"frame": dict(frame)}) + except Exception: + logger.debug("compute-host response relay failed sid=%s", sid, exc_info=True) + return True + + +def _lock_compute_host_clarify(rid: str, request_id: str, question_id: str, answer: str) -> dict | None: + """Proxy a batch-clarify lock into the child that owns the request; keeps the parent mirror's locked + answers current for reconnect snapshots. None when the request is not host-owned.""" + located = _compute_host_request_session(request_id) if located is None or not _session_uses_compute_host(located[1]): return None sid, session = located try: - ack = _get_compute_host_supervisor().respond(sid, params) + ack = _get_compute_host_supervisor().respond( + sid, {"lock": {"request_id": request_id, "question_id": question_id, "answer": answer}}) except Exception as exc: - return _err(rid, 5019, f"compute-host clarify response failed: {exc}") + return _err(rid, 5019, f"compute-host clarify lock failed: {exc}") if ack.get("type") == "respond.error": - return _err(rid, 5019, str(ack.get("message") or "compute-host clarify response failed")) + return _err(rid, 5019, str(ack.get("message") or "compute-host clarify lock failed")) response = ack.get("response") if not isinstance(response, dict): - return _err(rid, 5019, "compute-host clarify response returned an invalid response") + return _err(rid, 5019, "compute-host clarify lock returned an invalid response") if "error" in response: error = response["error"] if isinstance(response["error"], dict) else {} - return _err(rid, int(error.get("code") or 5000), - str(error.get("message") or "clarify response failed")) - result = response.get("result") - if not isinstance(result, dict): - return _err(rid, 5019, "compute-host clarify response returned an invalid result") - _update_compute_host_clarify_snapshot(sid, session, params, result) + return _err(rid, int(error.get("code") or 5000), str(error.get("message") or "clarify lock failed")) + result = response.get("result") if isinstance(response.get("result"), dict) else {} + with _history_lock(session): + if _open_request_matches(session, request_id): + mirrored = session["_compute_host_open_request"] + if result.get("status") == "expired" or result.get("remaining") == []: + session.pop("_compute_host_open_request", None) + else: + mirrored["params"]["answers"] = {**(mirrored["params"].get("answers") or {}), question_id: answer} return _ok(rid, result) @@ -200,7 +207,7 @@ def _on_compute_host_turn_done(rid: str, sid: str, session: dict, frame: dict) - session["running"] = False session["last_active"] = time.time() _clear_inflight_turn(session) - session.pop("_compute_host_pending_clarify", None) + session.pop("_compute_host_open_request", None) if frame.get("type") == "turn.error": message = str(frame.get("message") or "compute host turn failed") _emit("message.complete", sid, {"text": f"Error: {message}", "status": "error"}) -
tui_gateway/hosted_room_member_activity.py@@ -23,7 +23,6 @@ "tool.start": "tool.started", "tool.complete": "tool.completed", "tool.output_risk": "tool.output_risk", - "approval.request": "request.opened", "message.delta": "message.delta", "message.interim": "message.interim", "reasoning.delta": "reasoning.delta", @@ -42,20 +41,29 @@ def emit_room_member_activity(hosted_task: Mapping[str, Any], *, kind: str, payl return enqueue_plugin_stream_hook(HOOK_NAME, **coordinates, kind=kind, seq=seq, payload=dict(payload or {})) +# Server→client request frames (``{"id": "srq-…", "method": …}``) that are member activity. +KIND_BY_REQUEST_METHOD: Mapping[str, str] = {"approval": "request.opened"} + + def project_room_member_activity(frame: Mapping[str, Any], sessions: Mapping[str, Mapping[str, Any]]) -> bool: - """Fire the hook for an outgoing session event frame when its session is running a room turn.""" - if frame.get("method") != "event": - return False + """Fire the hook for an outgoing session frame (event notification or server→client request) when its + session is running a room turn.""" params = frame.get("params") if not isinstance(params, Mapping): return False - kind = KIND_BY_FRAME_TYPE.get(str(params.get("type") or "")) + if frame.get("method") == "event": + kind = KIND_BY_FRAME_TYPE.get(str(params.get("type") or "")) + payload = params.get("payload") + elif "id" in frame: + kind = KIND_BY_REQUEST_METHOD.get(str(frame.get("method") or "")) + payload = {k: v for k, v in params.items() if k != "session_id"} + else: + return False if kind is None: return False session = sessions.get(str(params.get("session_id") or "")) hosted_task = session.get("_hosted_room_task") if isinstance(session, Mapping) else None if not isinstance(hosted_task, Mapping): return False - payload = params.get("payload") return emit_room_member_activity( hosted_task, kind=kind, payload=payload if isinstance(payload, Mapping) else None, seq=params.get("seq")) -
tui_gateway/methods_prompt.py@@ -1093,26 +1093,47 @@ def cleanup(): cwd=preview_cwd, cleanup=cleanup) -# ── late-answer RPCs for tool-driven UI cards ─────────────────────────────── -# allow_expired=True everywhere: a tool's bounded wait can expire (its _pending entry -# popped) while the card is still visible; a late answer must not surface the raw 4009. +# ── batch clarify locks ───────────────────────────────────────────────────── +# A batch ``clarify`` server request is answered one question at a time: each lock is a normal RPC +# (update-in-place, editable until every qid is locked); the LAST lock resolves the request itself. +# A cancel-all is the plain response frame with no ``answers``. -@method("clarify.respond") +@method("clarify.lock") def _(rid, params: dict) -> dict: - if proxied := _respond_compute_host_clarify(rid, params): + request_id = str(params.get("request_id") or "") + question_id = str(params.get("question_id") or "") + if not request_id or not question_id: + return _err(rid, 4002, "request_id and question_id required") + answer = params.get("answer", "") + answer = answer if isinstance(answer, str) else json.dumps(answer, ensure_ascii=False) + if (proxied := _lock_compute_host_clarify(rid, request_id, question_id, answer)) is not None: return proxied - return _respond(rid, params, "answer", allow_expired=True) + from tui_gateway import server_requests + try: + remaining = server_requests.lock_answer(request_id, question_id, answer) + except ValueError as e: + return _err(rid, 4002, str(e)) + if remaining is None: + # The wait already ended (timeout / cancel) while the card was still visible: not an error. + return _ok(rid, {"status": "expired"}) + return _ok(rid, {"status": "ok", "remaining": remaining}) -_LATE_RESPOND_KEYS = { - "terminal.read.respond": "text", "preview.read.respond": "text", "preview.act.respond": "text", - "window.read.respond": "text", "tour.respond": "text", "mcp.setup.respond": "result", - "sudo.respond": "password", "secret.respond": "value", "vault.unlock.respond": "password", - "vault.save_login.respond": "login", "vault.code.respond": "code"} -for _name, _key in _LATE_RESPOND_KEYS.items(): - method(_name)(lambda rid, params, _k=_key: _respond(rid, params, _k, allow_expired=True)) -del _name, _key +@method("request.answer") +def _(rid, params: dict) -> dict: + """Answer an open server→client request from a client that did not receive it (a Bot Mode room + window answering a member's prompt mirrored from its resume snapshot). The response-frame path is + the norm; this is the proxy for it. ``expired`` when the request already ended.""" + request_id = str(params.get("id") or "") + result = params.get("result") + if not request_id or not isinstance(result, dict): + return _err(rid, 4002, "id and an object result required") + from tui_gateway import server_requests + frame = {"jsonrpc": "2.0", "id": request_id, "result": result} + if server_requests.resolve_response(frame) or _relay_compute_host_response(frame): + return _ok(rid, {"status": "ok"}) + return _ok(rid, {"status": "expired"}) # ── approvals ─────────────────────────────────────────────────────────────── -
tui_gateway/methods_session.py@@ -2177,8 +2177,10 @@ def _(rid, params: dict) -> dict: from tui_gateway import event_replay as er frames = er.events_since(sid, last_seen) # ``epoch``: in-process seq — clients reset watermarks when this differs from gateway.ready's. + # ``open_requests``: server→client requests still unanswered — the ring cannot carry "a question still + # waiting", so the reconnecting client re-delivers these to its request handlers. return _ok(rid, {"events": frames, "latest_seq": er.latest_seq(sid), "truncated": er.is_truncated(sid, last_seen), - "count": len(frames), "epoch": er.replay_epoch()}) + "count": len(frames), "epoch": er.replay_epoch(), "open_requests": _open_requests(sid)}) @method("session.events.stats") -
tui_gateway/server.py@@ -83,13 +83,6 @@ def _panic_hook(exc_type, exc_value, exc_tb): _sessions: dict[str, dict] = {} _methods: dict[str, callable] = {} -_pending: dict[str, tuple[str, threading.Event]] = {} -_pending_prompt_payloads: dict[str, tuple[str, dict]] = {} -_answers: dict[str, str] = {} -# Batch clarify accumulators: rid → {"qids": [...], "answers": {qid: answer}}. Written by -# clarify.respond (per-question lock, update-in-place), read out by _block on resolution/timeout -# so locked answers survive the deadline. -_batch_clarify: dict[str, dict] = {} _db = None _db_error: str | None = None _stdout_lock = threading.Lock() @@ -98,7 +91,6 @@ def _panic_hook(exc_type, exc_value, exc_tb): # compare/check/write transaction needs its own lock, not the unrelated config cache lock. _profile_ui_meta_lock = threading.Lock() _sessions_lock = threading.RLock() # reentrant: _close_session_by_id may run under callers that already hold it -_prompt_lock = threading.Lock() _cfg_cache: dict | None = None _cfg_mtime: float | None = None _cfg_path = None @@ -600,10 +592,12 @@ def write_json(obj: dict) -> bool: from tui_gateway.event_replay import _stamp_event from tui_gateway.hosted_room_member_activity import project_room_member_activity _stamp_event(obj) - if obj.get("method") == "event": - # A room member's hidden session has no transport: its frames would die at stdio below. + params = obj.get("params") + if obj.get("method") == "event" or (isinstance(obj.get("id"), str) and "method" in obj): + # Event notifications AND server→client requests carry ``params.session_id``; both route to the + # owning session's transport. A room member's hidden session has no transport: its frames would + # die at stdio below. project_room_member_activity(obj, _sessions) - params = obj.get("params") sid = ((params or {}).get("session_id")) if isinstance(params, dict) else "" if sid and (t := (_sessions.get(sid) or {}).get("transport")) is not None: return t.write(obj) @@ -619,6 +613,11 @@ def _emit(event: str, sid: str, payload: dict | None = None) -> bool: return write_json(_event_frame(event, sid, payload)) +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)) + + # Live WS peer transports (maintained by tui_gateway.ws): the only route for session-less background # events, which write_json would otherwise drop on stdio (see _broadcast_global_event). _live_transports: set[Transport] = set() @@ -669,25 +668,20 @@ def _approval_request_payload(data: dict | None) -> dict: return payload -def _pending_clarify_request_payload(sid: str) -> dict | None: - """Read-only snapshot of the clarify prompt still blocking a session: a client detached when - `clarify.request` was emitted would otherwise never see it (agent parked until timeout). Same replay - contract as `pending_approval`: the registry stays authoritative; `clarify.respond` resolves by request_id.""" - with _prompt_lock: - for rid, (owner_sid, _ev) in _pending.items(): - event, prompt_payload = _pending_prompt_payloads.get(rid, ("", {})) - if owner_sid != sid or event != "clarify.request": - continue - snapshot = dict(prompt_payload) - # Batch clarify: replay the answers locked so far so a reconnecting client restores its ✓ state. - if (batch := _batch_clarify.get(rid)) is not None and batch["answers"]: - snapshot["answers"] = dict(batch["answers"]) - return snapshot +def _open_requests(sid: str) -> list[dict]: + """Server→client requests still waiting on *sid*'s renderer, for reconnect snapshots (``session.resume`` / + ``session.activate`` / ``session.events.since``). A client detached when the request frame was written would + otherwise never see it (agent parked until timeout). Under turn isolation the compute-host child owns the + request; the parent mirrors it from the relayed frame (compute_host_bridge).""" + from tui_gateway import server_requests + reqs = server_requests.open_requests(sid) + if reqs: + return reqs if (session := _sessions.get(sid)) is not None: with session.get("history_lock", threading.Lock()): - pending = session.get("_compute_host_pending_clarify") - return dict(pending) if isinstance(pending, dict) else None - return None + mirrored = session.get("_compute_host_open_request") + return [dict(mirrored)] if isinstance(mirrored, dict) else [] + return [] def _pending_approval_request_payload(session_key: str) -> dict | None: @@ -702,12 +696,29 @@ def _pending_approval_request_payload(session_key: str) -> dict | None: def _emit_approval_request(sid: str, data: dict | None) -> None: - """Emit ``approval.request`` with the command redacted: a credential-shaped value Tirith flagged would - otherwise echo verbatim to the TUI (third egress alongside chat platforms and the SSE/API stream). + """Send an ``approval`` server request with the command redacted: a credential-shaped value Tirith flagged + would otherwise echo verbatim to the TUI (third egress alongside chat platforms and the SSE/API stream). + See #48456, #50767. + + The wait is owned by ``tools.approval``'s queue (its own timeout, ``/approve all``, coalescing), so the request + is queue-backed: the response resolves the queue entry, and the entry's own resolution (any surface, timeout, + interrupt) withdraws the request with ``request.cancel``.""" + from tui_gateway import server_requests + from tools import approval as _approval + payload = _approval_request_payload(data) + request_id = str(payload.get("request_id") or "") + session_key = str((_sessions.get(sid) or {}).get("session_key") or "") + + def on_result(result: dict | None) -> None: + if result is None: # withdrawn: the queue entry resolves on its own path + return + choice = str(result.get("choice") or "deny") + _approval.resolve_gateway_approval(session_key, choice, resolve_all=bool(result.get("all")), + request_id=request_id or None) - Reuse the shared gateway See #48456, #50767. - """ - _emit("approval.request", sid, _approval_request_payload(data)) + settle = server_requests.send_async("approval", sid, payload, on_result) + if request_id: + _approval.register_gateway_settle(session_key, request_id, settle) def _status_update(sid: str, kind: str, text: str | None = None): @@ -809,6 +820,12 @@ def dispatch(req: dict, transport: Optional[Transport] = None) -> dict | None: t = transport or _stdio_transport token = [redacted:secret-assignment] try: + 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): + logger.debug("dropping response for unknown server request id=%r", req.get("id")) + return None normalized = _normalize_request(req) if isinstance(normalized, dict): return normalized @@ -1257,55 +1274,13 @@ def _enable_gateway_prompts() -> None: # ── Blocking prompt factory ────────────────────────────────────────── -# Blocking bridges whose `*.respond` tolerates a late reply (allow_expired=True): on timeout the tool -# returns empty, but a slow renderer could still answer and hit a raw 4009 — `.expire` tears the card down. -_EXPIRING_REQUESTS = frozenset({ - "secret.request", "sudo.request", "vault.unlock.request", "vault.save_login.request", "vault.code.request", "clarify.request", - "terminal.read.request", - "preview.read.request", "preview.act.request", "window.read.request", "mcp.setup.request", - "tour.request", -}) - - -def _block(event: str, sid: str, payload: dict, timeout: float | None = 300, batch_qids: list[str] | None = None) -> str: - rid = uuid.uuid4().hex[:8] - ev = threading.Event() - with _prompt_lock: - _pending[rid] = (sid, ev) - payload["request_id"] = rid - _pending_prompt_payloads[rid] = (event, dict(payload)) - if batch_qids: - # Multi-question clarify: per-question answers accumulate here (update-in-place until every - # qid is locked); locked answers survive a timeout — see the batch read-out below. - _batch_clarify[rid] = {"qids": list(batch_qids), "answers": {}} - answered, batch_answers = False, None - try: - _emit(event, sid, payload) - # Event semantics: None → wait forever (clarify_timeout <= 0; released only by a real answer or - # session.interrupt), 0 → return immediately, > 0 → bounded wait. - answered = ev.wait(timeout) - finally: - with _prompt_lock: - _pending.pop(rid, None) - _pending_prompt_payloads.pop(rid, None) - answer_present = rid in _answers - answer = _answers.pop(rid, "") - if (batch_state := _batch_clarify.pop(rid, None)) is not None: - batch_answers = dict(batch_state["answers"]) - expire = lambda: _emit(f"{event.removesuffix('.request')}.expire", sid, {"request_id": rid}) - if batch_qids is not None: - # Cancel-all (respond with no question_id) resolves via _answers with "" — a plain cancel, not a partial result. - if answer_present: - return answer - result: dict[str, object] = {"answers": batch_answers or {}} - if not answered: - # Deadline hit: keep what was locked, report the rest as absences (not skips), still expire live cards. - result["timed_out"] = True - expire() - return json.dumps(result, ensure_ascii=False) - if not answered and not answer_present and event in _EXPIRING_REQUESTS: - expire() - return answer +def _ask(method: str, sid: str, params: dict, timeout: float | None = 300) -> str: + """Server→client request whose answer is one string under ``value`` (sudo, secret, vault prompts, GUI reads, + MCP setup). Empty string when the renderer skipped, timed out, or was cancelled.""" + from tui_gateway import server_requests + result = server_requests.send(method, sid, params, timeout=timeout) + value = (result or {}).get("value", "") + return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False) def _clarify_timeout_seconds() -> float | None: @@ -1319,16 +1294,23 @@ def _clarify_timeout_seconds() -> float | None: def _clarify_block(sid: str, q, c, multi_select=False, questions=None) -> str: - """Bridge the clarify tool callback onto _block. Single-question payloads keep their historical shape - (``multi_select`` only when True — older renderers never see a new field); batch calls emit one - clarify.request with only the wire fields (the tool-side entries carry result-assembly keys too).""" + """Bridge the clarify tool callback onto a ``clarify`` server request. Single question: the response is + ``{"answer"}`` ("" = skip). Batch: one request with only the wire fields (tool-side entries carry + result-assembly keys too); answers lock one at a time through ``clarify.lock`` and the tool gets + ``{"answers", "timed_out"?}`` as JSON — a response with no ``answers`` is a cancel-all.""" + from tui_gateway import server_requests if questions: wire = [{"qid": e["qid"], "question": e["question"], "choices": e["choices"], "multi_select": bool(e["multi_select"])} for e in questions] - return _block("clarify.request", sid, {"questions": wire}, timeout=_clarify_timeout_seconds(), - batch_qids=[e["qid"] for e in questions]) - payload = {"question": q, "choices": c, "multi_select": True} if multi_select else {"question": q, "choices": c} - return _block("clarify.request", sid, payload, timeout=_clarify_timeout_seconds()) + result = server_requests.send("clarify", sid, {"questions": wire}, timeout=_clarify_timeout_seconds(), + qids=[e["qid"] for e in questions]) + if not result or "answers" not in result: + return "" + return json.dumps(result, ensure_ascii=False) + params = {"question": q, "choices": c, "multi_select": True} if multi_select else {"question": q, "choices": c} + result = server_requests.send("clarify", sid, params, timeout=_clarify_timeout_seconds()) + answer = (result or {}).get("answer", "") + return answer if isinstance(answer, str) else "" # A tour action is a DOM op the renderer answers in ms; the generous deadline exists only because a @@ -1346,12 +1328,12 @@ def _clarify_block(sid: str, q, c, multi_select=False, questions=None) -> str: def _tour_request(sid: str, payload: dict) -> str: - """Bridge the tour tool callback onto _block without paying for a client that cannot answer: against - an older app nobody calls ``tour.respond`` and each action would block the full deadline, stacking per + """Bridge the tour tool callback onto a ``tour`` server request without paying for a client that cannot answer: against + an older app nobody answers ``tour`` and each action would block the full deadline, stacking per turn. First action per session gets the short probe deadline; unanswered → bridge marked unavailable for that session; once answered, the full deadline. Verdict lives on the record, so a new session re-probes. - The renderer's ``tour.request`` handler ships in the desktop bundle, but the tool is offered by this + The renderer's ``tour`` handler ships in the desktop bundle, but the tool is offered by this backend — and the two update on different clocks. The model then does what the schema tells it to and tries the next action, so a single "give me a tour" turn stacks those waits (the timeouts reported against #89620). @@ -1362,8 +1344,8 @@ def _tour_request(sid: str, payload: dict) -> str: state = session.get("tour_bridge") if state == "unanswered": return _TOUR_BRIDGE_UNAVAILABLE - answer = _block("tour.request", sid, dict(payload), - timeout=_TOUR_TIMEOUT_S if state == "answered" else _TOUR_PROBE_TIMEOUT_S) + answer = _ask("tour", sid, dict(payload), + timeout=_TOUR_TIMEOUT_S if state == "answered" else _TOUR_PROBE_TIMEOUT_S) if answer: session["tour_bridge"] = "answered" elif state != "answered": @@ -1372,13 +1354,10 @@ def _tour_request(sid: str, payload: dict) -> str: def _clear_pending(sid: str | None = None) -> None: - """Release pending prompts with an empty answer: only *sid*'s (session.interrupt must not cancel other - sessions' prompts), or every one when *sid* is None (shutdown).""" - with _prompt_lock: - for rid, (owner_sid, ev) in list(_pending.items()): - if sid is None or owner_sid == sid: - _answers[rid] = "" - ev.set() + """Withdraw open server→client requests: only *sid*'s (session.interrupt must not cancel other sessions' + prompts), or every one when *sid* is None (process exit). Each one gets a ``request.cancel``.""" + from tui_gateway import server_requests + server_requests.cancel(sid, reason="interrupted" if sid else "shutdown") # ── Agent factory ──────────────────────────────────────────────────── @@ -2624,8 +2603,9 @@ def _run() -> None: def _session_pending_kind(sid: str) -> str: - return next((str(_pending_prompt_payloads.get(rid, ("input.request", {}))[0]).removesuffix(".request") - for rid, (owner_sid, _ev) in list(_pending.items()) if owner_sid == sid), "") + """Method of the server→client request *sid* is blocked on ("" when none).""" + from tui_gateway import server_requests + return server_requests.pending_kind(sid) def _session_live_status(sid: str, session: dict) -> str: @@ -2775,7 +2755,7 @@ def _live_session_payload( } for key, value in (("inflight", inflight), ("queued", queued), ("pending_approval", _pending_approval_request_payload(str(session.get("session_key") or ""))), - ("pending_clarify", _pending_clarify_request_payload(sid))): + ("open_requests", _open_requests(sid))): if value: payload[key] = value return _attach_todo_state(payload, session) @@ -3077,31 +3057,6 @@ def _loop() -> None: return stop, thread -# ── Methods: respond ───────────────────────────────────────────────── - - -def _respond(rid, params, key, *, allow_expired=False): - r = params.get("request_id", "") - question_id = str(params.get("question_id") or "") - with _prompt_lock: - entry = _pending.get(r) - if not entry: - return _ok(rid, {"status": "expired"}) if allow_expired and r else _err(rid, 4009, f"no pending {key} request") - _, ev = entry - batch = _batch_clarify.get(r) - if batch is not None and question_id: - # Per-question lock; update-in-place so an answer stays editable until every qid is locked (Confirm). - if question_id not in batch["qids"]: - return _err(rid, 4002, f"unknown question_id {question_id!r}") - batch["answers"][question_id] = params.get(key, "") - if not (remaining := [qid for qid in batch["qids"] if qid not in batch["answers"]]): - ev.set() - return _ok(rid, {"status": "ok", "remaining": remaining}) - _answers[r] = params.get(key, "") - ev.set() - return _ok(rid, {"status": "ok"}) - - # ── Methods: tools & system ────────────────────────────────────────── -
tui_gateway/server_requests.py@@ -0,0 +1,224 @@ +"""Server→client JSON-RPC requests: the backend asks the renderer a question and waits for the +response frame carrying the same ``id``. + +JSON-RPC is peer-to-peer; this is the backend's half. Every "ask the renderer" bridge (clarify, +approval, sudo, secret, vault prompts, desktop GUI reads, MCP setup consent, the tour) is one +:func:`send` (blocking) or :func:`send_async` (queue-backed approvals) and one response frame from +the client — no paired ``*.request`` notification / ``*.respond`` method, no per-kind ``*.expire``. + +Ids are ``srq-<12 hex>``: strings never collide with client-minted integer ids, and the random +part keeps a compute-host child's requests distinct from the parent's when both reach one socket. +A request that times out or is cancelled (interrupt, session close, shutdown) emits ONE +``request.cancel {id, method, reason}`` notification so every renderer tears the card down the +same way. A response for an id that is no longer open is dropped — the wait already returned. + +Reconnect: unanswered requests are returned as ``open_requests`` by ``session.resume`` / +``session.activate`` / ``session.events.since`` (:func:`open_requests`); the shared TypeScript +channel re-delivers them as if they had just arrived, so the notification replay ring never +has to carry "a question still waiting for an answer". + +Batch clarify keeps per-question locks (``clarify.lock`` → :func:`lock_answer`): answers stay +editable until every question is locked, locked answers survive a timeout, and the last lock +resolves the request with the full answer set. +""" + +from __future__ import annotations + +import logging +import threading +import time +import uuid +from typing import Any, Callable + +logger = logging.getLogger(__name__) + + +class ServerRequest: + __slots__ = ("id", "sid", "method", "params", "event", "result", "answered", "created_at", + "qids", "locked", "on_result") + + def __init__(self, sid: str, method: str, params: dict, *, qids: list[str] | None = None, + on_result: Callable[[dict | None], None] | None = None) -> None: + self.id = f"srq-{uuid.uuid4().hex[:12]}" + self.sid = sid + self.method = method + self.params = dict(params) + self.event = threading.Event() + self.result: dict | None = None + self.answered = False + self.created_at = time.time() + # Batch clarify: question ids still to lock, and the answers locked so far. + self.qids = list(qids) if qids else None + self.locked: dict[str, str] = {} + self.on_result = on_result + + def frame(self) -> dict: + return {"jsonrpc": "2.0", "id": self.id, "method": self.method, + "params": {"session_id": self.sid, **self.params}} + + def snapshot(self) -> dict: + """``open_requests`` entry: the request as sent, plus the batch answers locked so far so a + reconnecting client restores its ✓ state.""" + params = {"session_id": self.sid, **self.params} + if self.locked: + params["answers"] = dict(self.locked) + return {"id": self.id, "method": self.method, "params": params} + + +_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 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: + _emit("request.cancel", req.sid, {"id": req.id, "method": req.method, "reason": reason}) + + +def _register(req: ServerRequest) -> None: + with _lock: + _open[req.id] = req + _write(req.frame()) + + +def send(method: str, sid: str, params: dict, *, timeout: float | None, + qids: list[str] | None = None) -> dict | None: + """Send one request and block for the response ``result`` (a dict). + + Returns ``None`` when the renderer never answered (timeout, cancel, or an error response — e.g. + a client without a handler for ``method``). ``timeout`` semantics: None → wait until answered or + cancelled, 0 → return immediately, > 0 → bounded wait. A batch (``qids``) that times out + returns ``{"answers": <locked so far>, "timed_out": True}`` instead of None. + """ + req = ServerRequest(sid, method, params, qids=qids) + _register(req) + timed_out = False + try: + timed_out = not req.event.wait(timeout) + finally: + with _lock: + _open.pop(req.id, None) + if timed_out: + _emit_cancel(req, "timeout") + if req.qids is not None: + return {"answers": dict(req.locked), "timed_out": True} + return None + return req.result if req.answered else None + + +def send_async(method: str, sid: str, params: dict, on_result: Callable[[dict | None], None]) -> Callable[[str], None]: + """Send one request whose wait is owned elsewhere (the approval queue's own timeout). ``on_result`` + runs on the dispatching thread when the response lands. Returns ``settle(reason)``: call it when + the underlying wait ends; if the request is still open it is withdrawn with ``request.cancel``.""" + req = ServerRequest(sid, method, params, on_result=on_result) + _register(req) + + def settle(reason: str) -> None: + with _lock: + still_open = _open.pop(req.id, None) is not None + if still_open: + _emit_cancel(req, reason) + + return settle + + +def resolve_response(frame: dict) -> 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).""" + rid = frame.get("id") + if not isinstance(rid, str): + return False + with _lock: + req = _open.get(rid) + if req is None: + return False + if req.on_result is not None: + _open.pop(rid, None) + if "error" in frame: + logger.debug("server request %s (%s) answered with error: %s", rid, req.method, frame.get("error")) + req.result, req.answered = None, False + 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) + req.event.set() + return True + + +def lock_answer(request_id: str, question_id: str, answer: str) -> list[str] | None: + """Lock one batch-clarify answer (update-in-place). Returns the question ids still unanswered; + the last lock resolves the request with the full ``{"answers"}`` set. ``None`` when no open + batch has that id (expired or foreign); ``ValueError`` for an unknown question id.""" + with _lock: + req = _open.get(request_id) + if req is None or req.qids is None: + return None + if question_id not in req.qids: + raise ValueError(f"unknown question_id {question_id!r}") + req.locked[question_id] = answer + remaining = [qid for qid in req.qids if qid not in req.locked] + if not remaining: + req.result, req.answered = {"answers": dict(req.locked)}, True + if not remaining: + req.event.set() + return remaining + + +def cancel(sid: str | None = None, reason: str = "interrupted") -> int: + """Withdraw open requests — only *sid*'s (session.interrupt must not touch other sessions'), or + every one when *sid* is None (shutdown). Blocked waits return None; queue-backed requests run + ``on_result(None)`` so their owner can settle. Returns the number withdrawn.""" + with _lock: + targets = [req for req in _open.values() if sid is None or req.sid == sid] + for req in targets: + _open.pop(req.id, None) + for req in targets: + req.result, req.answered = None, False + if req.on_result is not None: + req.on_result(None) + req.event.set() + _emit_cancel(req, reason) + return len(targets) + + +def open_requests(sid: str) -> list[dict]: + """Unanswered requests for *sid*, oldest first.""" + with _lock: + reqs = sorted((req for req in _open.values() if req.sid == sid), key=lambda r: r.created_at) + return [req.snapshot() for req in reqs] + + +def pending_kind(sid: str) -> str: + """Method of the oldest open request for *sid* ("" when none) — the session is waiting on a human.""" + with _lock: + reqs = [req for req in _open.values() if req.sid == sid] + return min(reqs, key=lambda r: r.created_at).method if reqs else "" + + +def is_response_frame(obj: Any) -> bool: + """A client response: has an ``id`` and a ``result``/``error`` member but no ``method``.""" + return isinstance(obj, dict) and "method" not in obj and "id" in obj and ("result" in obj or "error" in obj) + + +def reset_for_tests() -> None: + with _lock: + _open.clear() -
ui-tui/README.md@@ -229,14 +229,18 @@ Tool/status activity is shown in a live activity lane. Transcript rows stay focu ## Prompt flows -The Python gateway can pause the main loop and request structured input: - -- `approval.request`: allow once, allow for session, allow always, or deny -- `clarify.request`: pick from choices or type a custom answer -- `sudo.request`: masked password entry -- `secret.request`: masked value entry for a named env var +The Python gateway can pause the main loop and ask the client a question. These are JSON-RPC +**requests from the server** (string id, answered with a response frame of the same id — see +`createServerRequestHandler.ts`), not events: + +- `approval`: allow once, allow for session, allow always, or deny → `{ choice }` +- `clarify`: pick from choices or type a custom answer → `{ answer }` (batch: `{ answers }`) +- `sudo`: masked password entry → `{ value }` +- `secret`: masked value entry for a named env var → `{ value }` - `session.list`: used by `SessionPicker` for `/resume` +A withdrawn question (timeout, interrupt) arrives as a `request.cancel` event carrying its id. + These are stateful UI branches in `app.tsx`, not separate screens. ## Commands @@ -304,12 +308,7 @@ Primary event types the client handles today: | `tool.generating` | `{ name }` | | `tool.progress` | `{ name, preview }` | | `tool.complete` | `{ tool_id, name, error?, summary?, duration_s?, inline_diff?, todos? }` | -| `clarify.request` | `{ question, choices?, request_id }` | -| `approval.request` | `{ command, description, allow_permanent? }` | -| `sudo.request` | `{ request_id }` | -| `sudo.expire` | `{ request_id }` clears a timed-out sudo prompt | -| `secret.request` | `{ prompt, env_var, request_id }` | -| `secret.expire` | `{ request_id }` clears a timed-out secret prompt | +| `request.cancel` | `{ id, method, reason }` clears the withdrawn server→client request | | `background.complete` | `{ task_id, text }` | | `billing.step_up.verification` | `{ verification_url, user_code }` | | `review.summary` | `{ text }` | -
ui-tui/src/__tests__/createGatewayEventHandler.test.ts@@ -1,7 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createGatewayEventHandler } from '../app/createGatewayEventHandler.js' +import { createServerRequestHandler } from '../app/createServerRequestHandler.js' import { getOverlayState, patchOverlayState, resetOverlayState } from '../app/overlayStore.js' +import { resetServerRequestsForTests } from '../app/serverRequestStore.js' import { turnController } from '../app/turnController.js' import { getTurnState, resetTurnState } from '../app/turnStore.js' import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js' @@ -58,11 +60,27 @@ const buildCtx = (appended: Msg[]) => } }) as any +/** Deliver one server→client request (`tui_gateway/server_requests.py`) to the TUI's request handler. */ +const serverRequest = (method: string, params: Record<string, unknown>, id = `srq-${method}`) => { + const respond = vi.fn() + + const handled = createServerRequestHandler({ ringPromptBell: vi.fn(), setStatus: status => patchUiState({ status }) })({ + fail: vi.fn(), + id, + method, + params, + respond + }) + + return { handled, respond } +} + describe('createGatewayEventHandler', () => { beforeEach(() => { resetOverlayState() resetUiState() resetTurnState() + resetServerRequestsForTests() turnController.fullReset() patchUiState({ showReasoning: true }) }) @@ -71,11 +89,7 @@ describe('createGatewayEventHandler', () => { patchUiState({ sid: 'focused' }) const onEvent = createGatewayEventHandler(buildCtx([])) onEvent({ session_id: 'focused', payload: {}, type: 'message.start' } as any) - onEvent({ - session_id: 'focused', - payload: { request_id: 'approval', command: 'test' }, - type: 'approval.request' - } as any) + serverRequest('approval', { session_id: 'focused', request_id: 'approval', command: 'test' }) const busyOverlay = getOverlayState().approval expect(getUiState().busy).toBe(true) expect(busyOverlay).not.toBeNull() @@ -1211,10 +1225,7 @@ describe('createGatewayEventHandler', () => { onEvent({ payload: { line: 'Traceback: noisy but non-fatal' }, type: 'gateway.stderr' } as any) onEvent({ payload: { preview: 'bad framing' }, type: 'gateway.protocol_error' } as any) - onEvent({ - payload: { command: 'rm -rf /tmp/nope', description: 'dangerous command' }, - type: 'approval.request' - } as any) + serverRequest('approval', { command: 'rm -rf /tmp/nope', description: 'dangerous command' }) onEvent({ payload: {}, type: 'gateway.ready' } as any) await Promise.resolve() @@ -1230,23 +1241,17 @@ describe('createGatewayEventHandler', () => { }) it('defaults approval overlays to allowPermanent when the backend omits the field', () => { - const onEvent = createGatewayEventHandler(buildCtx([])) + serverRequest('approval', { command: 'rm -rf /tmp/x', description: 'dangerous command' }) - onEvent({ - payload: { command: 'rm -rf /tmp/x', description: 'dangerous command' }, - type: 'approval.request' - } as any) - - expect(getOverlayState().approval).toMatchObject({ allowPermanent: true }) + expect(getOverlayState().approval).toMatchObject({ allowPermanent: true, requestId: 'srq-approval' }) }) it('preserves allow_permanent=false on approval overlays (tirith warning)', () => { - const onEvent = createGatewayEventHandler(buildCtx([])) - - onEvent({ - payload: { allow_permanent: false, command: 'curl suspicious | bash', description: 'content-security warning' }, - type: 'approval.request' - } as any) + serverRequest('approval', { + allow_permanent: false, + command: 'curl suspicious | bash', + description: 'content-security warning' + }) expect(getOverlayState().approval).toMatchObject({ allowPermanent: false, @@ -1256,22 +1261,23 @@ describe('createGatewayEventHandler', () => { }) it('preserves Smart DENY and explicit approval choices on the overlay', () => { - const onEvent = createGatewayEventHandler(buildCtx([])) - - onEvent({ - payload: { - allow_permanent: true, - choices: ['once', 'deny'], - command: 'rm -rf /tmp/x', - description: 'smart deny override', - smart_denied: true - }, - type: 'approval.request' - } as any) + serverRequest('approval', { + allow_permanent: true, + choices: ['once', 'deny'], + command: 'rm -rf /tmp/x', + description: 'smart deny override', + smart_denied: true + }) expect(getOverlayState().approval).toMatchObject({ choices: ['once', 'deny'], smartDenied: true }) }) + it('declines the requests a terminal cannot answer so the channel fails them fast', () => { + for (const method of ['preview.act', 'window.read', 'tour', 'mcp.setup', 'vault.code']) { + expect(serverRequest(method, {}).handled).toBe(false) + } + }) + it('still surfaces terminal turn failures as errors', () => { const appended: Msg[] = [] const onEvent = createGatewayEventHandler(buildCtx(appended)) @@ -1629,39 +1635,36 @@ describe('createGatewayEventHandler', () => { expect(appended.some(msg => msg.role === 'system' && msg.text.startsWith('ask '))).toBe(false) }) - it('clears only the matching sensitive prompt when the gateway expires it', () => { + it('clears only the card whose request the gateway withdrew (request.cancel by id)', () => { const onEvent = createGatewayEventHandler(buildCtx([])) - patchOverlayState({ - secret: { envVar: 'NEW_KEY', prompt: 'Enter new key', requestId: 'secret-new' }, - sudo: { requestId: 'sudo-1' } - }) + serverRequest('secret', { env_var: 'NEW_KEY', prompt: 'Enter new key' }, 'secret-new') + serverRequest('sudo', {}, 'sudo-1') - onEvent({ payload: { request_id: 'secret-old' }, type: 'secret.expire' } as any) + onEvent({ payload: { id: 'secret-old', method: 'secret', reason: 'timeout' }, type: 'request.cancel' } as any) expect(getOverlayState().secret?.requestId).toBe('secret-new') - onEvent({ payload: { request_id: 'secret-new' }, type: 'secret.expire' } as any) + onEvent({ payload: { id: 'secret-new', method: 'secret', reason: 'timeout' }, type: 'request.cancel' } as any) expect(getOverlayState().secret).toBeNull() + expect(getOverlayState().sudo?.requestId).toBe('sudo-1') - onEvent({ payload: { request_id: 'sudo-1' }, type: 'sudo.expire' } as any) + onEvent({ payload: { id: 'sudo-1', method: 'sudo', reason: 'interrupted' }, type: 'request.cancel' } as any) expect(getOverlayState().sudo).toBeNull() }) // ── Batch (multi-question) clarify ───────────────────────────────── - it('parses a batch clarify.request into a questions overlay', () => { - const onEvent = createGatewayEventHandler(buildCtx([])) - - onEvent({ - payload: { + it('parses a batch clarify request into a questions overlay', () => { + serverRequest( + 'clarify', + { questions: [ { choices: ['a', 'b'], qid: 'q0', question: 'One?' }, { choices: null, qid: 'q1', question: 'Two?' } - ], - request_id: 'req-batch' + ] }, - type: 'clarify.request' - } as any) + 'req-batch' + ) const clarify = getOverlayState().clarify expect(clarify?.requestId).toBe('req-batch') @@ -1671,39 +1674,35 @@ describe('createGatewayEventHandler', () => { expect(clarify?.answers).toEqual({}) }) - it('seeds locked answers from a reconnect-replay batch clarify.request', () => { - const onEvent = createGatewayEventHandler(buildCtx([])) - - onEvent({ - payload: { + it('seeds locked answers from a reconnect-replayed batch clarify request', () => { + serverRequest( + 'clarify', + { answers: { q0: 'a' }, questions: [ { choices: ['a', 'b'], qid: 'q0', question: 'One?' }, { choices: null, qid: 'q1', question: 'Two?' } - ], - request_id: 'req-replay' + ] }, - type: 'clarify.request' - } as any) + 'req-replay' + ) expect(getOverlayState().clarify?.answers).toEqual({ q0: 'a' }) }) it('drops malformed batch entries and falls back to single-question shape when none survive', () => { - const onEvent = createGatewayEventHandler(buildCtx([])) - - onEvent({ - payload: { + serverRequest( + 'clarify', + { choices: ['x', 'y'], question: 'Fallback?', questions: [ { qid: '', question: 'no qid' }, { qid: 'q1', question: ' ' } - ], - request_id: 'req-bad' + ] }, - type: 'clarify.request' - } as any) + 'req-bad' + ) const clarify = getOverlayState().clarify expect(clarify?.questions).toBeUndefined() -
ui-tui/src/__tests__/useInputHandlers.test.ts@@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { getOverlayState, patchOverlayState, resetOverlayState } from '../app/overlayStore.js' +import { rememberServerRequest, resetServerRequestsForTests } from '../app/serverRequestStore.js' import { applyVoiceRecordResponse, dismissSensitivePrompt, @@ -156,31 +157,37 @@ describe('applyVoiceRecordResponse', () => { }) describe('dismissSensitivePrompt', () => { - it('clears a sudo overlay before a stale cancel RPC resolves', async () => { + const openRequest = (id: string, method: string) => { + const respond = vi.fn() + + rememberServerRequest({ fail: vi.fn(), id, method, params: {}, respond }) + + return respond + } + + it('clears a sudo overlay and answers the server request with an empty value', () => { resetOverlayState() - patchOverlayState({ sudo: { requestId: 'sudo-1' } }) - const rpc = vi.fn().mockResolvedValue(null) + resetServerRequestsForTests() + patchOverlayState({ sudo: { requestId: 'srq-sudo' } }) + const respond = openRequest('srq-sudo', 'sudo') const sys = vi.fn() - const pending = dismissSensitivePrompt(getOverlayState(), rpc, sys) + dismissSensitivePrompt(getOverlayState(), vi.fn(), sys) expect(getOverlayState().sudo).toBeNull() expect(sys).toHaveBeenCalledWith('sudo cancelled') - expect(rpc).toHaveBeenCalledWith('sudo.respond', { password: '', request_id: 'sudo-1' }) - await pending + expect(respond).toHaveBeenCalledWith({ value: '' }) }) - it('clears a secret overlay before a stale cancel RPC resolves', async () => { + it('clears a secret overlay even when its request already expired (nothing left to answer)', () => { resetOverlayState() - patchOverlayState({ secret: { envVar: 'API_KEY', prompt: 'Enter API key', requestId: 'secret-1' } }) - const rpc = vi.fn().mockResolvedValue(null) + resetServerRequestsForTests() + patchOverlayState({ secret: { envVar: 'API_KEY', prompt: 'Enter API key', requestId: 'srq-gone' } }) const sys = vi.fn() - const pending = dismissSensitivePrompt(getOverlayState(), rpc, sys) + dismissSensitivePrompt(getOverlayState(), vi.fn(), sys) expect(getOverlayState().secret).toBeNull() expect(sys).toHaveBeenCalledWith('secret entry cancelled') - expect(rpc).toHaveBeenCalledWith('secret.respond', { request_id: 'secret-1', value: '' }) - await pending }) }) -
ui-tui/src/app/createGatewayEventHandler.ts@@ -31,6 +31,7 @@ import { applyDelegationStatus, getDelegationState } from './delegationStore.js' import type { GatewayEventHandlerContext, NoticeLevel } from './interfaces.js' import { getOverlayState, patchOverlayState } from './overlayStore.js' import { flashGoodVibes, flashPet } from './petFlashStore.js' +import { forgetServerRequest } from './serverRequestStore.js' import { turnController } from './turnController.js' import { getTurnState } from './turnStore.js' import { getUiState, patchUiState } from './uiStore.js' @@ -449,8 +450,8 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: // paths can't both persist the same prompt twice. const persistedAbandonedClarify = new Set<string>() - // When a clarify prompt is dismissed without an answer (the backend _block - // timed out and returned an empty string), the live ClarifyPrompt overlay is + // When a clarify prompt is dismissed without an answer (the backend request + // timed out and returned no answer), the live ClarifyPrompt overlay is // left set until the next turn's idle() silently nulls it — so the question // and options vanish from the screen while the agent's follow-up still refers // to them. The reliable signal is the clarify tool's own tool.complete (and, @@ -1257,124 +1258,30 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return } - case 'clarify.request': { - if (!ev.payload) { - return - } - - const batch = (ev.payload.questions ?? []) - .filter(q => typeof q?.qid === 'string' && q.qid && typeof q?.question === 'string' && q.question.trim()) - .map(q => ({ - choices: q.choices && q.choices.length > 0 ? q.choices : null, - multiSelect: q.multi_select === true, - qid: q.qid, - question: q.question.trim() - })) - - patchOverlayState({ - clarify: batch.length - ? { - answers: ev.payload.answers ?? {}, - choices: null, - question: '', - questions: batch, - requestId: ev.payload.request_id - } - : { - choices: ev.payload.choices ?? null, - question: ev.payload.question ?? '', - requestId: ev.payload.request_id - } - }) - setStatus('waiting for input…') - ringPromptBell() - - return - } - - case 'approval.request': { - if (!ev.payload) { - return - } + case 'request.cancel': { + // The backend withdrew a server→client request (timeout / interrupt / + // session close): tear down whichever card carries that id. A clarify + // that timed out is persisted as an abandoned prompt by tool.complete. + const id = ev.payload?.id - const description = String(ev.payload.description ?? 'dangerous command') - // Only an explicit false (tirith warning) drops the permanent-allow option. - const allowPermanent = ev.payload.allow_permanent !== false - - patchOverlayState({ - approval: { - allowPermanent, - choices: ev.payload.choices, - command: String(ev.payload.command ?? ''), - description, - smartDenied: ev.payload.smart_denied === true - } - }) - setStatus('approval needed') - ringPromptBell() - - return - } - - case 'sudo.request': - if (!ev.payload) { + if (!id) { return } - patchOverlayState({ sudo: { requestId: ev.payload.request_id } }) - setStatus('sudo password needed') - ringPromptBell() - - return + forgetServerRequest(id) + patchOverlayState(prev => { + const next = { ...prev } + let changed = false - case 'secret.request': - if (!ev.payload) { - return - } - - patchOverlayState({ - secret: { envVar: ev.payload.env_var, prompt: ev.payload.prompt, requestId: ev.payload.request_id } - }) - setStatus('secret input needed') - ringPromptBell() - - return - case 'sudo.expire': { - const expired = ev.payload?.request_id - - patchOverlayState(prev => (prev.sudo?.requestId === expired ? { ...prev, sudo: null } : prev)) - - return - } - - case 'secret.expire': { - const expired = ev.payload?.request_id - - patchOverlayState(prev => (prev.secret?.requestId === expired ? { ...prev, secret: null } : prev)) - - return - } - - case 'vault.unlock.request': - if (!ev.payload) { - return - } - - patchOverlayState({ - vaultUnlock: { - backend: ev.payload.backend, - displayName: ev.payload.display_name, - requestId: ev.payload.request_id + for (const key of ['approval', 'clarify', 'secret', 'sudo', 'vaultUnlock'] as const) { + if (prev[key]?.requestId === id) { + next[key] = null + changed = true + } } - }) - setStatus(`unlock ${ev.payload.display_name}`) - ringPromptBell() - return - case 'vault.unlock.expire': { - const expired = ev.payload?.request_id - - patchOverlayState(prev => (prev.vaultUnlock?.requestId === expired ? { ...prev, vaultUnlock: null } : prev)) + return changed ? next : prev + }) return } -
ui-tui/src/app/createServerRequestHandler.ts@@ -0,0 +1,114 @@ +import type { ServerRequest } from '@hermes/shared/json-rpc-channel' + +import type { ClarifyBatchQuestion } from '../types.js' + +import { patchOverlayState } from './overlayStore.js' +import { rememberServerRequest } from './serverRequestStore.js' + +export interface ServerRequestHandlerContext { + ringPromptBell: () => void + setStatus: (status: string) => void +} + +const str = (v: unknown): string => (typeof v === 'string' ? v : '') + +const strList = (v: unknown): null | string[] => + Array.isArray(v) && v.length > 0 ? v.filter((c): c is string => typeof c === 'string') : null + +/** + * The Ink TUI's answer to the backend's server→client requests + * (`tui_gateway/server_requests.py`). Each method opens its overlay card; + * the card's answer path resolves the request through `serverRequestStore`. + * Methods the terminal cannot answer (desktop GUI bridges: `preview.*`, + * `window.read`, `tour`, `mcp.setup`, `terminal.read`, the vault card + * prompts) return `false` so the channel answers `-32601` and the tool + * fails fast instead of waiting out its deadline. + */ +export function createServerRequestHandler(ctx: ServerRequestHandlerContext): (request: ServerRequest) => boolean { + const { ringPromptBell, setStatus } = ctx + + const open = (request: ServerRequest, status: string) => { + rememberServerRequest(request) + setStatus(status) + + if (!request.replayed) { + ringPromptBell() + } + } + + return request => { + const p = request.params + + switch (request.method) { + case 'clarify': { + const batch: ClarifyBatchQuestion[] = (Array.isArray(p.questions) ? (p.questions as unknown[]) : []) + .map(raw => (raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {})) + .filter(q => str(q.qid) && str(q.question).trim()) + .map(q => ({ + choices: strList(q.choices), + multiSelect: q.multi_select === true, + qid: str(q.qid), + question: str(q.question).trim() + })) + + const answers = + p.answers && typeof p.answers === 'object' + ? Object.fromEntries( + Object.entries(p.answers as Record<string, unknown>).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' + ) + ) + : {} + + patchOverlayState({ + clarify: batch.length + ? { answers, choices: null, question: '', questions: batch, requestId: request.id } + : { choices: strList(p.choices), question: str(p.question), requestId: request.id } + }) + open(request, 'waiting for input…') + + return true + } + + case 'approval': { + patchOverlayState({ + approval: { + // Only an explicit false (tirith warning) drops the permanent-allow option. + allowPermanent: p.allow_permanent !== false, + choices: strList(p.choices) ?? undefined, + command: str(p.command), + description: str(p.description) || 'dangerous command', + requestId: request.id, + smartDenied: p.smart_denied === true + } + }) + open(request, 'approval needed') + + return true + } + + case 'sudo': + patchOverlayState({ sudo: { requestId: request.id } }) + open(request, 'sudo password needed') + + return true + + case 'secret': + patchOverlayState({ secret: { envVar: str(p.env_var), prompt: str(p.prompt), requestId: request.id } }) + open(request, 'secret input needed') + + return true + + case 'vault.unlock_prompt': + patchOverlayState({ + vaultUnlock: { backend: str(p.backend), displayName: str(p.display_name), requestId: request.id } + }) + open(request, `unlock ${str(p.display_name)}`) + + return true + + default: + return false + } + } +} -
ui-tui/src/app/serverRequestStore.ts@@ -0,0 +1,38 @@ +import type { ServerRequest } from '@hermes/shared/json-rpc-channel' + +// Live server→client requests (clarify, approval, sudo, …) keyed by request +// id. Overlay state keeps only the id; answering resolves the stored request +// so a re-delivered (`open_requests`) request with the same id reuses the +// same card. Module-level, like the overlay store: the gateway client and +// the Ink handlers share one instance per process. +const open = new Map<string, ServerRequest>() + +export function rememberServerRequest(request: ServerRequest): void { + open.set(request.id, request) +} + +export function forgetServerRequest(id: string): void { + open.delete(id) +} + +/** Answer request `id` and forget it. False when nothing is open under that id (expired / already answered). */ +export function respondToServerRequest(id: string, result: Record<string, unknown>): boolean { + const request = open.get(id) + + if (!request) { + return false + } + + open.delete(id) + request.respond(result) + + return true +} + +export function hasOpenServerRequest(id: string): boolean { + return open.has(id) +} + +export function resetServerRequestsForTests(): void { + open.clear() +} -
ui-tui/src/app/useInputHandlers.ts@@ -5,13 +5,7 @@ import { useEffect, useRef } from 'react' import { DASHBOARD_TUI_MODE } from '../config/env.js' import { DOUBLE_ESC_MS, TYPING_IDLE_MS } from '../config/timing.js' import { applyCompletion } from '../domain/slash.js' -import type { - ApprovalRespondResponse, - ConfigSetResponse, - SecretRespondResponse, - SudoRespondResponse, - VoiceRecordResponse -} from '../gatewayTypes.js' +import type { ConfigSetResponse, VoiceRecordResponse } from '../gatewayTypes.js' import { isAction, isCopyShortcut, isMac, isVoiceToggleKey } from '../lib/platform.js' import { computePrecisionWheelStep, initPrecisionWheel } from '../lib/precisionWheel.js' import { computeWheelStep, initWheelAccelForHost } from '../lib/wheelAccel.js' @@ -27,6 +21,7 @@ import { type OverlayState } from './interfaces.js' import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js' +import { respondToServerRequest } from './serverRequestStore.js' import { turnController } from './turnController.js' import { patchTurnState } from './turnStore.js' import { getUiState } from './uiStore.js' @@ -153,7 +148,9 @@ export function dismissSensitivePrompt( patchOverlayState({ sudo: null }) sys('sudo cancelled') - return rpc<SudoRespondResponse>('sudo.respond', { password: '', request_id: requestId }) + respondToServerRequest(requestId, { value: '' }) + + return } if (overlay.secret) { @@ -162,7 +159,9 @@ export function dismissSensitivePrompt( patchOverlayState({ secret: null }) sys('secret entry cancelled') - return rpc<SecretRespondResponse>('secret.respond', { request_id: requestId, value: '' }) + respondToServerRequest(requestId, { value: '' }) + + return } if (overlay.vaultUnlock) { @@ -171,7 +170,7 @@ export function dismissSensitivePrompt( patchOverlayState({ vaultUnlock: null }) sys(`${overlay.vaultUnlock.displayName} stays locked`) - return rpc<SecretRespondResponse>('vault.unlock.respond', { password: '', request_id: requestId }) + respondToServerRequest(requestId, { value: '' }) } } @@ -228,9 +227,11 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { } if (overlay.approval) { - return gateway - .rpc<ApprovalRespondResponse>('approval.respond', { choice: 'deny', session_id: getUiState().sid }) - .then(r => r && (patchOverlayState({ approval: null }), patchTurnState({ outcome: 'denied' }))) + respondToServerRequest(overlay.approval.requestId, { choice: 'deny' }) + patchOverlayState({ approval: null }) + patchTurnState({ outcome: 'denied' }) + + return } if (overlay.sudo || overlay.secret || overlay.vaultUnlock) { -
ui-tui/src/app/useMainApp.ts@@ -8,6 +8,7 @@ import { useStdout, useTerminalTitle } from '@hermes/ink' +import { JSON_RPC_METHOD_NOT_FOUND, type ServerRequest } from '@hermes/shared/json-rpc-channel' import { useStore } from '@nanostores/react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -22,7 +23,7 @@ import { type GatewayClient } from '../gatewayClient.js' import type { SubagentListResponse } from '../gatewayTypes.js' import type { AnyGatewayEvent, - ClarifyRespondResponse, + ClarifyLockResponse, ConfigSetResponse, SessionActiveListResponse, SessionCloseResponse, @@ -49,6 +50,7 @@ import type { Msg, PanelSection, SlashCatalog } from '../types.js' import { applyAgentSnapshot } from './agentRoster.js' import { createGatewayEventHandler } from './createGatewayEventHandler.js' +import { createServerRequestHandler } from './createServerRequestHandler.js' import { createSlashHandler } from './createSlashHandler.js' import { planGatewayRecovery } from './gatewayRecovery.js' import { getInputSelection } from './inputSelectionStore.js' @@ -56,6 +58,7 @@ import { type GatewayRpc, type StateSetter, type TranscriptRow } from './interfa import { $overlayState, patchOverlayState } from './overlayStore.js' import { $goodVibesTick } from './petFlashStore.js' import { scrollWithSelectionBy } from './scroll.js' +import { respondToServerRequest } from './serverRequestStore.js' import { turnController } from './turnController.js' import { patchTurnState, useTurnSelector } from './turnStore.js' import { $uiState, getUiState, patchUiState } from './uiStore.js' @@ -232,6 +235,7 @@ export function useMainApp(gw: GatewayClient) { const colsRef = useRef(cols) const scrollRef = useRef<null | ScrollBoxHandle>(null) const onEventRef = useRef<(ev: AnyGatewayEvent) => void>(() => {}) + const onServerRequestRef = useRef<(request: ServerRequest) => boolean>(() => false) const sysRef = useRef<(text: string) => void>(() => {}) const submitRef = useRef<(value: string) => void>(() => {}) const submitLiteralRef = useRef<(value: string) => void>(() => {}) @@ -710,11 +714,14 @@ export function useMainApp(gw: GatewayClient) { turnController.turnTools = turnController.turnTools.filter(line => !sameToolTrailGroup(label, line)) patchTurnState({ turnTrail: turnController.turnTools }) - rpc<ClarifyRespondResponse>('clarify.respond', { answer, request_id: clarify.requestId }).then(r => { - if (!r) { - return - } + if (!respondToServerRequest(clarify.requestId, { answer })) { + // The request already expired (request.cancel raced the keystroke): nothing to answer. + patchOverlayState({ clarify: null }) + return + } + + { if (answer) { turnController.persistedToolLabels.add(label) appendMessage({ @@ -738,14 +745,14 @@ export function useMainApp(gw: GatewayClient) { } patchOverlayState({ clarify: null }) - }) + } }, - [appendMessage, overlay.clarify, rpc] + [appendMessage, overlay.clarify] ) - // Lock one answer of a batch clarify (clarify.respond + question_id). The - // overlay stays up until the server reports no remaining questions — the - // final lock resolves the tool and the turn continues. + // Lock one answer of a batch clarify (`clarify.lock` RPC). The overlay stays + // up until the server reports no remaining questions — the final lock + // resolves the server request and the turn continues. const answerClarifyQuestion = useCallback( (qid: string, answer: string) => { const clarify = overlay.clarify @@ -754,7 +761,7 @@ export function useMainApp(gw: GatewayClient) { return } - rpc<ClarifyRespondResponse & { remaining?: string[] }>('clarify.respond', { + rpc<ClarifyLockResponse>('clarify.lock', { answer, question_id: qid, request_id: clarify.requestId @@ -765,6 +772,12 @@ export function useMainApp(gw: GatewayClient) { const answers = { ...(clarify.answers ?? {}), [qid]: answer } + if (r.status === 'expired') { + patchOverlayState({ clarify: null }) + + return + } + if ((r.remaining ?? []).length > 0) { patchOverlayState({ clarify: { ...clarify, answers } }) @@ -908,9 +921,30 @@ export function useMainApp(gw: GatewayClient) { onEventRef.current = onEvent + const onServerRequest = useMemo( + () => + createServerRequestHandler({ + ringPromptBell: () => { + if (bellOnPrompt && stdout?.isTTY) { + stdout.write('\x07') + } + }, + setStatus: status => patchUiState({ status }) + }), + [bellOnPrompt, stdout] + ) + + onServerRequestRef.current = onServerRequest + useEffect(() => { const handler = (ev: AnyGatewayEvent) => onEventRef.current(ev) + const requestHandler = (request: ServerRequest) => { + if (!onServerRequestRef.current(request)) { + request.fail(JSON_RPC_METHOD_NOT_FOUND, `the terminal UI cannot answer ${request.method}`) + } + } + const exitHandler = () => { turnController.reset() @@ -946,12 +980,14 @@ export function useMainApp(gw: GatewayClient) { } gw.on('event', handler) + gw.on('request', requestHandler) gw.on('exit', exitHandler) gw.drain() // entry.tsx's setupGracefulExit handles process cleanup on real exit. return () => { gw.off('event', handler) + gw.off('request', requestHandler) gw.off('exit', exitHandler) } }, [gw, sys]) @@ -1015,19 +1051,26 @@ export function useMainApp(gw: GatewayClient) { slashRef.current = slash - const respondWith = useCallback( - (method: string, params: Record<string, unknown>, done: () => void) => rpc(method, params).then(r => r && done()), - [rpc] - ) + // Answer a server→client request by id; the card closes either way (an + // expired request has nothing left to answer). + const respondWith = useCallback((requestId: string, result: Record<string, unknown>, done: () => void) => { + respondToServerRequest(requestId, result) + done() + }, []) const answerApproval = useCallback( - (choice: string) => - respondWith('approval.respond', { choice, session_id: ui.sid }, () => { + (choice: string) => { + if (!overlay.approval) { + return + } + + respondWith(overlay.approval.requestId, { choice }, () => { patchOverlayState({ approval: null }) patchTurnState({ outcome: choice === 'deny' ? 'denied' : `approved (${choice})` }) patchUiState({ status: 'running…' }) - }), - [respondWith, ui.sid] + }) + }, + [overlay.approval, respondWith] ) const answerSudo = useCallback( @@ -1042,7 +1085,7 @@ export function useMainApp(gw: GatewayClient) { patchOverlayState({ sudo: null }) } - return respondWith('sudo.respond', { password: pw, request_id: requestId }, () => { + respondWith(requestId, { value: pw }, () => { patchOverlayState({ sudo: null }) patchUiState({ status: 'running…' }) }) @@ -1062,7 +1105,7 @@ export function useMainApp(gw: GatewayClient) { patchOverlayState({ secret: null }) } - return respondWith('secret.respond', { request_id: requestId, value }, () => { + respondWith(requestId, { value }, () => { patchOverlayState({ secret: null }) patchUiState({ status: 'running…' }) }) @@ -1082,7 +1125,7 @@ export function useMainApp(gw: GatewayClient) { patchOverlayState({ vaultUnlock: null }) } - return respondWith('vault.unlock.respond', { password, request_id: requestId }, () => { + respondWith(requestId, { value: password }, () => { patchOverlayState({ vaultUnlock: null }) patchUiState({ status: 'running…' }) }) -
ui-tui/src/gatewayClient.ts@@ -9,6 +9,7 @@ import { DEFAULT_HEARTBEAT_DEADLINE_MS, DEFAULT_HEARTBEAT_INTERVAL_MS, JsonRpcRequestChannel, + type ServerRequest, wireFrameText } from '@hermes/shared/json-rpc-channel' import { reconnectBackoffDelayMs } from '@hermes/shared/reconnect-backoff' @@ -134,10 +135,15 @@ export class GatewayClient extends EventEmitter { private readonly channel = new JsonRpcRequestChannel({ onEvent: ev => this.publish(ev as AnyGatewayEvent), onHeartbeatFailure: () => this.onHeartbeatFailure(), + onUnhandledRequest: req => this.pushLog(`[protocol] unhandled server request: ${req.method}`), requestTimeoutMs: REQUEST_TIMEOUT_MS, unrefTimers: true }) private bufferedEvents = new CircularBuffer<AnyGatewayEvent>(MAX_BUFFERED_EVENTS) + // Server→client requests (clarify, approval, sudo, …) follow the same + // mount-order contract as events: an attached session mid-turn can send one + // the instant the socket opens, before the Ink handler is registered. + private bufferedRequests: ServerRequest[] = [] private pendingExit: number | null | undefined private ready = false private readyTimer: ReturnType<typeof setTimeout> | null = null @@ -155,6 +161,13 @@ export class GatewayClient extends EventEmitter { // useInput / createGatewayEventHandler can legitimately attach many // listeners. Default 10-cap triggers spurious warnings. this.setMaxListeners(0) + this.channel.onRequest(request => { + if (this.subscribed) { + this.emit('request', request) + } else { + this.bufferedRequests.push(request) + } + }) } private publish(ev: AnyGatewayEvent) { @@ -280,6 +293,7 @@ export class GatewayClient extends EventEmitter { // its queued microtask becomes a no-op (it captured the old generation). this.drainGeneration += 1 this.bufferedEvents.clear() + this.bufferedRequests = [] this.pendingExit = undefined this.stdoutRl?.close() this.stderrRl?.close() @@ -673,6 +687,10 @@ export class GatewayClient extends EventEmitter { this.emit('event', ev) } + for (const request of this.bufferedRequests.splice(0)) { + this.emit('request', request) + } + if (this.pendingExit !== undefined) { const code = this.pendingExit -
ui-tui/src/gatewayTypes.ts@@ -316,20 +316,10 @@ export interface BackgroundStartResponse { task_id?: string } -export interface ClarifyRespondResponse { - ok?: boolean -} - -export interface ApprovalRespondResponse { - ok?: boolean -} - -export interface SudoRespondResponse { - ok?: boolean -} - -export interface SecretRespondResponse { - ok?: boolean +/** `clarify.lock` — one batch-clarify answer locked; `expired` when the request already ended. */ +export interface ClarifyLockResponse { + remaining?: string[] + status: 'expired' | 'ok' } // ── Shell / clipboard / input ──────────────────────────────────────── -
ui-tui/src/types.ts@@ -100,6 +100,8 @@ export interface ApprovalReq { choices?: string[] command: string description: string + /** Server→client request id; the answer is the response frame for it. */ + requestId: string smartDenied?: boolean } -
website/docs/developer-guide/programmatic-integration.md@@ -46,8 +46,7 @@ session.create session.list session.active_list session.activate session.close session.interrupt session.history session.compress session.branch session.title session.usage session.status -clarify.respond sudo.respond secret.respond -approval.respond config.set / config.get commands.catalog +clarify.lock config.set / config.get commands.catalog command.resolve command.dispatch cli.exec reload.mcp reload.env process.stop delegation.status subagent.interrupt subagent.steer @@ -76,7 +75,20 @@ On a successful truncating submit against a durable session, the `prompt.submit` ### Events streamed back -`message.delta`, `message.complete`, `tool.start`, `tool.generating`, `tool.complete`, `approval.request`, `clarify.request`, `sudo.request`, `sudo.expire`, `secret.request`, `secret.expire`, `gateway.ready`, plus session lifecycle and error events. Expiry events carry the original `{ request_id }`; external hosts should clear only the matching pending prompt. +`message.delta`, `message.complete`, `tool.start`, `tool.generating`, `tool.complete`, `gateway.ready`, `request.cancel`, plus session lifecycle and error events. + +### Server→client requests (questions the agent asks you) + +Approvals, clarify questions, sudo/secret prompts, vault unlock, MCP setup and the desktop read/act bridges are **JSON-RPC requests from the gateway to the client**, not events. The frame carries a string id, and the client answers with a normal JSON-RPC response bearing the same id: + +``` +← {"jsonrpc":"2.0","id":"srq-7","method":"approval","params":{"session_id":"…","request_id":"…","command":"rm -rf build","description":"…"}} +→ {"jsonrpc":"2.0","id":"srq-7","result":{"choice":"once"}} +``` + +Methods: `approval` → `{choice}`; `clarify` → `{answer}` (single) or `{answers}` / `{}` cancel (batch, with `clarify.lock` to lock one answer early); `sudo`, `secret`, `vault.code`, `vault.unlock` → `{value}`; `mcp.setup` → `{result}`; `terminal.read`, `window.read`, `preview.act`, `tour` → `{value}` (JSON text). Respond with a JSON-RPC error (`-32601`) for a method your host does not implement so the agent fails fast instead of waiting out the timeout. + +When the gateway withdraws a question (timeout, interrupt, answered from another surface) it emits `request.cancel` `{ id, method, reason }`; clear only the matching prompt. `session.resume` / `session.activate` results and `session.events.since` carry `open_requests` — the still-open frames — so a reconnecting client re-renders (and can still answer) them. ### Pi-style RPC mapping @@ -94,7 +106,7 @@ Every command in the Pi-mono RPC spec ([issue #360](https://github.com/NousResea | `get_messages` | `session.history` | | `switch_session` | `session.resume` | | `fork` | `session.branch` | -| `ui_request` / `ui_response` | `clarify.respond` / `sudo.respond` / `secret.respond` / `approval.respond` | +| `ui_request` / `ui_response` | server→client requests `clarify` / `sudo` / `secret` / `approval` answered by JSON-RPC response frames | ---
-
-
#114972 fix(desktop): name the session in approval and input-request OS toasts (#114337, salvage #114383)
files:
apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/server-requests.test.ts,apps/desktop/src/i18n/ar.ts,apps/desktop/src/i18n/en.ts,apps/desktop/src/i18n/ja.ts,apps/desktop/src/i18n/ru.ts,apps/desktop/src/i18n/types.ts,apps/desktop/src/i18n/zh-hant.ts,apps/desktop/src/i18n/zh.ts,apps/desktop/src/store/native-notifications.test.ts,apps/desktop/src/store/native-notifications.ts
3 PRs closed without merging
From the close comments: superseded by another PR 2 · done elsewhere 1
-
#86621 fix(desktop): report waiting status when a session is blocked on approval · done elsewhere →
#86565,#110677,#114972 -
#88140 fix(gateway): report approval-blocked sessions as waiting in live status · superseded by another PR →
#86772,#86565 -
#104501 feat(desktop): say which session needs you — sidebar chip, amber badge, named notification (+ fix approval-blocked status) · superseded by another PR →
1879088,#114972,#114337,#114383
Closed issues · 1
- Closed#86565[Bug]: Desktop — session dot stays blue (Session running) while blocked on approval; turns amber (Needs your input) only after opening the session
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.29: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab