diff --git a/apps/desktop/src/components/prompt-overlays.test.tsx b/apps/desktop/src/components/prompt-overlays.test.tsx new file mode 100644 index 000000000000..7b8cb2148f5e --- /dev/null +++ b/apps/desktop/src/components/prompt-overlays.test.tsx @@ -0,0 +1,67 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, 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 { $activeSessionId } from '@/store/session' + +import { PromptOverlays } from './prompt-overlays' + +vi.mock('@/lib/haptics', () => ({ triggerHaptic: vi.fn() })) +vi.mock('@/store/notifications', () => ({ notifyError: vi.fn() })) + +function renderPrompts() { + render( + + + + ) +} + +afterEach(() => { + cleanup() + clearAllPrompts() + $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')) + + $activeSessionId.set('s1') + $gateway.set({ request } as never) + 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(request).toHaveBeenCalledWith('sudo.respond', { password: '', request_id: 'sudo-1' }) + 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')) + + $activeSessionId.set('s1') + $gateway.set({ request } as never) + 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(request).toHaveBeenCalledWith('secret.respond', { request_id: 'secret-1', value: '' }) + expect(notifyError).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/components/prompt-overlays.tsx b/apps/desktop/src/components/prompt-overlays.tsx index a43303e1ced3..cf56d62e85ad 100644 --- a/apps/desktop/src/components/prompt-overlays.tsx +++ b/apps/desktop/src/components/prompt-overlays.tsx @@ -15,6 +15,7 @@ import { } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { useI18n } from '@/i18n' +import { isMissingPendingPromptRequest } from '@/lib/gateway-rpc' import { triggerHaptic } from '@/lib/haptics' import { KeyRound, Loader2, Lock } from '@/lib/icons' import { $gateway } from '@/store/gateway' @@ -69,6 +70,12 @@ function SudoDialog() { triggerHaptic('submit') clearSudoRequest(request.sessionId, request.requestId) } catch (error) { + if (isMissingPendingPromptRequest(error, 'password')) { + clearSudoRequest(request.sessionId, request.requestId) + + return + } + notifyError(error, copy.sudoSendFailed) setSubmitting(false) } @@ -165,6 +172,12 @@ function SecretDialog() { triggerHaptic('submit') clearSecretRequest(request.sessionId, request.requestId) } catch (error) { + if (isMissingPendingPromptRequest(error, 'value')) { + clearSecretRequest(request.sessionId, request.requestId) + + return + } + notifyError(error, copy.secretSendFailed) setSubmitting(false) } diff --git a/apps/desktop/src/lib/gateway-rpc.test.ts b/apps/desktop/src/lib/gateway-rpc.test.ts index 6da30b87b765..6c84c12ecaa8 100644 --- a/apps/desktop/src/lib/gateway-rpc.test.ts +++ b/apps/desktop/src/lib/gateway-rpc.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { isMissingRpcMethod } from './gateway-rpc' +import { isMissingPendingPromptRequest, isMissingRpcMethod } from './gateway-rpc' describe('isMissingRpcMethod', () => { it('detects JSON-RPC method-not-found errors', () => { @@ -14,3 +14,15 @@ describe('isMissingRpcMethod', () => { expect(isMissingRpcMethod(new Error('no such project'))).toBe(false) }) }) + +describe('isMissingPendingPromptRequest', () => { + it('detects stale prompt response errors from the gateway', () => { + expect(isMissingPendingPromptRequest(new Error('no pending password request'), 'password')).toBe(true) + expect(isMissingPendingPromptRequest(new Error('RPC failed: no pending value request'), 'value')).toBe(true) + }) + + it('ignores unrelated gateway failures', () => { + expect(isMissingPendingPromptRequest(new Error('gateway not connected'), 'password')).toBe(false) + expect(isMissingPendingPromptRequest(new Error('no pending value request'), 'password')).toBe(false) + }) +}) diff --git a/apps/desktop/src/lib/gateway-rpc.ts b/apps/desktop/src/lib/gateway-rpc.ts index a209aefbd001..6cf298402f32 100644 --- a/apps/desktop/src/lib/gateway-rpc.ts +++ b/apps/desktop/src/lib/gateway-rpc.ts @@ -4,3 +4,10 @@ export function isMissingRpcMethod(error: unknown): boolean { return /method not found|-32601|unknown method|no such method/i.test(message) } + +/** True when a prompt response raced a backend-side timeout / completion. */ +export function isMissingPendingPromptRequest(error: unknown, key: string): boolean { + const message = error instanceof Error ? error.message : String(error) + + return message.toLowerCase().includes(`no pending ${key.toLowerCase()} request`) +}