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.
This commit is contained in:
LeonardoLGDS
2026-09-09 12:59:14 -04:00
committed by brooklyn!
parent 0fc30d63ab
commit 243bee0c9f
3 changed files with 93 additions and 4 deletions

View File

@@ -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') {

View File

@@ -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()
})

View File

@@ -37,9 +37,14 @@ export interface QuickEntrySubmitRelay {
begin: (forward: (correlationId: string) => void) => Promise<QuickEntrySubmitRelayResult>
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<string>()
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
}
}
}