From 243bee0c9fdabe39fa688c4595e2bdc98ef58139 Mon Sep 17 00:00:00 2001 From: LeonardoLGDS <54513066+LeonardoLGDS@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:59:14 -0400 Subject: [PATCH] fix(quick-entry): treat a relay timeout as unknown and reconcile late acks A relay timeout no longer deletes the correlation or invites a retry: it resolves as an unknown, non-retryable outcome and keeps the correlation reconcilable. A late ack for a timed-out submit fires onLateResult exactly once instead of being dropped, and never hides the window behind the user. main.ts forwards that outcome to the capture window over hermes:quick-entry:late-result. Regression: unknown-timeout contract, late success, late failure, and duplicate-late-ack coverage in electron/quick-entry.test.ts. --- apps/desktop/electron/main.ts | 8 +++ apps/desktop/electron/quick-entry.test.ts | 63 ++++++++++++++++++++++- apps/desktop/electron/quick-entry.ts | 26 +++++++++- 3 files changed, 93 insertions(+), 4 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 735688f7e6..ab7eccec0e 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -18091,6 +18091,14 @@ ipcMain.handle('hermes:quick-entry:settings:set', async (_event, patch) => { // payload is `{ target, text }` — target routing (current chat / a picked // session / new) is the renderer's job too. const quickEntrySubmitRelay = createQuickEntrySubmitRelay({ + // A late ack for a timed-out submit proves the outcome. Forward it to the + // capture window so it can reconcile the unknown state instead of the user + // resending a prompt that may already be delivered. + onLateResult: (correlationId, result) => { + if (quickEntryWindow && !quickEntryWindow.isDestroyed()) { + quickEntryWindow.webContents.send('hermes:quick-entry:late-result', { correlationId, result }) + } + }, onSuccess: () => { hideQuickEntryWindow() if (process.platform === 'darwin') { diff --git a/apps/desktop/electron/quick-entry.test.ts b/apps/desktop/electron/quick-entry.test.ts index 68b80bd3cd..ffea129b08 100644 --- a/apps/desktop/electron/quick-entry.test.ts +++ b/apps/desktop/electron/quick-entry.test.ts @@ -31,15 +31,74 @@ describe('quick-entry submit ack relay', () => { expect(relay.pendingCount()).toBe(0) }) - it('resolves an unacknowledged submit as a timeout and cleans it up', async () => { + it('resolves an unacknowledged submit as an unknown, non-retryable timeout and keeps it reconcilable', async () => { vi.useFakeTimers() const relay = createQuickEntrySubmitRelay({ onSuccess: vi.fn(), timeoutMs: 15000 }) const pending = relay.begin(vi.fn()) await vi.advanceTimersByTimeAsync(15000) - await expect(pending).resolves.toMatchObject({ code: 'timeout', ok: false }) + await expect(pending).resolves.toMatchObject({ code: 'timeout', ok: false, retryable: false }) expect(relay.pendingCount()).toBe(0) + expect(relay.reconcilableCount()).toBe(1) + vi.useRealTimers() + }) + + it('reconciles a late success through onLateResult without hiding the window', async () => { + vi.useFakeTimers() + const onSuccess = vi.fn() + const onLateResult = vi.fn() + const relay = createQuickEntrySubmitRelay({ onLateResult, onSuccess, timeoutMs: 15000 }) + let correlationId = '' + const pending = relay.begin(id => { + correlationId = id + }) + + await vi.advanceTimersByTimeAsync(15000) + await pending + relay.acknowledge(correlationId, { ok: true }) + + expect(onLateResult).toHaveBeenCalledTimes(1) + expect(onLateResult).toHaveBeenCalledWith(correlationId, { ok: true }) + expect(onSuccess).not.toHaveBeenCalled() + expect(relay.reconcilableCount()).toBe(0) + vi.useRealTimers() + }) + + it('reconciles a late failure through onLateResult', async () => { + vi.useFakeTimers() + const onLateResult = vi.fn() + const relay = createQuickEntrySubmitRelay({ onLateResult, onSuccess: vi.fn(), timeoutMs: 15000 }) + let correlationId = '' + const pending = relay.begin(id => { + correlationId = id + }) + + await vi.advanceTimersByTimeAsync(15000) + await pending + relay.acknowledge(correlationId, { code: 'submit-failed', ok: false }) + + expect(onLateResult).toHaveBeenCalledTimes(1) + expect(relay.reconcilableCount()).toBe(0) + vi.useRealTimers() + }) + + it('ignores a duplicate late ack', async () => { + vi.useFakeTimers() + const onLateResult = vi.fn() + const relay = createQuickEntrySubmitRelay({ onLateResult, onSuccess: vi.fn(), timeoutMs: 15000 }) + let correlationId = '' + const pending = relay.begin(id => { + correlationId = id + }) + + await vi.advanceTimersByTimeAsync(15000) + await pending + relay.acknowledge(correlationId, { ok: true }) + relay.acknowledge(correlationId, { ok: true }) + + expect(onLateResult).toHaveBeenCalledTimes(1) + expect(relay.reconcilableCount()).toBe(0) vi.useRealTimers() }) diff --git a/apps/desktop/electron/quick-entry.ts b/apps/desktop/electron/quick-entry.ts index 91b987f117..825aca85ee 100644 --- a/apps/desktop/electron/quick-entry.ts +++ b/apps/desktop/electron/quick-entry.ts @@ -37,9 +37,14 @@ export interface QuickEntrySubmitRelay { begin: (forward: (correlationId: string) => void) => Promise acknowledge: (correlationId: string, result: QuickEntrySubmitRelayResult) => void pendingCount: () => number + /** Correlations whose outcome is UNKNOWN: the relay timed out but the backend + * may still have accepted the prompt. Kept so a late ack reconciles instead + * of being silently dropped. */ + reconcilableCount: () => number } export function createQuickEntrySubmitRelay(options: { + onLateResult?: (correlationId: string, result: QuickEntrySubmitRelayResult) => void onSuccess: () => void timeoutMs?: number }): QuickEntrySubmitRelay { @@ -47,12 +52,23 @@ export function createQuickEntrySubmitRelay(options: { string, { resolve: (result: QuickEntrySubmitRelayResult) => void; timer: NodeJS.Timeout } >() + // Timed-out correlations. Delivery is UNCONFIRMED, so a late ack must + // reconcile; a correlation is never resolved twice. + const reconcilable = new Set() let sequence = 0 return { acknowledge(correlationId, result) { const request = pending.get(correlationId) + if (!request) { + // A late ack for a timed-out submit: the outcome is now known. Do NOT + // hide the window here — the user may already be typing again. The + // renderer reconciles the unknown outcome itself. + if (reconcilable.delete(correlationId)) { + options.onLateResult?.(correlationId, result) + } + return } @@ -69,11 +85,14 @@ export function createQuickEntrySubmitRelay(options: { return new Promise(resolve => { const timer = setTimeout(() => { pending.delete(correlationId) + // UNKNOWN, not failed: the prompt may already be accepted, so this + // must never invite a retry. Keep the correlation for late acks. + reconcilable.add(correlationId) resolve({ code: 'timeout', - message: 'Hermes did not confirm the prompt in time.', + message: 'Hermes has not confirmed the prompt yet — it may still be delivered.', ok: false, - retryable: true + retryable: false }) }, options.timeoutMs ?? 15_000) pending.set(correlationId, { resolve, timer }) @@ -82,6 +101,9 @@ export function createQuickEntrySubmitRelay(options: { }, pendingCount() { return pending.size + }, + reconcilableCount() { + return reconcilable.size } } }