From 16414418377ba7ca32fae372726966f81fa9c7ca Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 1 Jul 2026 06:33:47 -0700 Subject: [PATCH] fix(desktop): don't false-timeout long prompt.submit turns (MoA, deep reasoning) (#56411) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prompt.submit is fire-and-forget — turn completion is signaled by stream / message.complete events, not the RPC return — but it inherited the generic 30s default RPC timeout. A turn that legitimately takes >30s to ACK (MoA presets running references + aggregator in series, deep reasoning, large tool chains) popped a false 'request timed out: prompt.submit' toast at 30s while the turn was still running and streamed its real answer in 60-120s later (#55024). Add PROMPT_SUBMIT_REQUEST_TIMEOUT_MS (1_800_000 = the backend's agent.gateway_timeout ceiling) and pass it on all four prompt.submit call sites (submit, resume-recovery retry, regenerate, rewind), mirroring the existing SESSION_LIST_REQUEST_TIMEOUT_MS opt-out precedent. Widen the GatewayRequest type (+ the inline requestGateway prop type) to carry the optional timeoutMs the runtime impl already accepts. Tests: use-prompt-actions/index.test.tsx 34/34 pass; tsc -b clean. --- .../hooks/use-prompt-actions/index.test.tsx | 79 ++++++++++++------- .../session/hooks/use-prompt-actions/index.ts | 32 +++++--- .../hooks/use-prompt-actions/submit.ts | 9 ++- .../session/hooks/use-prompt-actions/utils.ts | 6 +- apps/desktop/src/hermes.ts | 9 +++ 5 files changed, 93 insertions(+), 42 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index 2647f4dcef1..d0290aa27fc 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -12,6 +12,7 @@ import { uploadComposerAttachment, usePromptActions } from '.' vi.mock('@/hermes', () => ({ getProfiles: vi.fn(async () => ({ profiles: [] })), + PROMPT_SUBMIT_REQUEST_TIMEOUT_MS: 1_800_000, setApiRequestProfile: vi.fn(), transcribeAudio: vi.fn() })) @@ -365,10 +366,14 @@ describe('usePromptActions submit / queue drain semantics', () => { // every delta of this brand-new turn. expect(seeds.length).toBeGreaterThan(0) expect(seeds.every(s => s.interrupted === false)).toBe(true) - expect(requestGateway).toHaveBeenCalledWith('prompt.submit', { - session_id: RUNTIME_SESSION_ID, - text: 'hello after a stop' - }) + expect(requestGateway).toHaveBeenCalledWith( + 'prompt.submit', + { + session_id: RUNTIME_SESSION_ID, + text: 'hello after a stop' + }, + 1_800_000 + ) }) it('a fromQueue drain sends even when busyRef is still true on the settle edge', async () => { @@ -390,10 +395,14 @@ describe('usePromptActions submit / queue drain semantics', () => { const accepted = await handle!.submitText('queued message', { fromQueue: true }) expect(accepted).toBe(true) - expect(requestGateway).toHaveBeenCalledWith('prompt.submit', { - session_id: RUNTIME_SESSION_ID, - text: 'queued message' - }) + expect(requestGateway).toHaveBeenCalledWith( + 'prompt.submit', + { + session_id: RUNTIME_SESSION_ID, + text: 'queued message' + }, + 1_800_000 + ) }) it('a rejected fromQueue drain returns false (entry stays queued) and a later retry sends it', async () => { @@ -430,10 +439,14 @@ describe('usePromptActions submit / queue drain semantics', () => { const second = await handle!.submitText('please send me', { fromQueue: true }) expect(second).toBe(true) - expect(requestGateway).toHaveBeenCalledWith('prompt.submit', { - session_id: RUNTIME_SESSION_ID, - text: 'please send me' - }) + expect(requestGateway).toHaveBeenCalledWith( + 'prompt.submit', + { + session_id: RUNTIME_SESSION_ID, + text: 'please send me' + }, + 1_800_000 + ) }) it('rides out a transient "session busy" so the user never sees it (retries, no error bubble)', async () => { @@ -593,11 +606,15 @@ describe('usePromptActions restoreToMessage', () => { // Ordinal 0 = "truncate before the first visible user message": the gateway // drops that turn and everything after, then runs the same text again. - expect(requestGateway).toHaveBeenCalledWith('prompt.submit', { - session_id: RUNTIME_SESSION_ID, - text: 'first prompt', - truncate_before_user_ordinal: 0 - }) + expect(requestGateway).toHaveBeenCalledWith( + 'prompt.submit', + { + session_id: RUNTIME_SESSION_ID, + text: 'first prompt', + truncate_before_user_ordinal: 0 + }, + 1_800_000 + ) expect((lastState.messages as { id: string }[]).map(m => m.id)).toEqual(['u1']) expect(lastState.busy).toBe(true) }) @@ -656,11 +673,15 @@ describe('usePromptActions restoreToMessage', () => { expect(requestGateway).toHaveBeenCalledWith('session.interrupt', { session_id: RUNTIME_SESSION_ID }) expect(submitAttempts).toBe(2) - expect(requestGateway).toHaveBeenCalledWith('prompt.submit', { - session_id: RUNTIME_SESSION_ID, - text: 'first prompt', - truncate_before_user_ordinal: 0 - }) + expect(requestGateway).toHaveBeenCalledWith( + 'prompt.submit', + { + session_id: RUNTIME_SESSION_ID, + text: 'first prompt', + truncate_before_user_ordinal: 0 + }, + 1_800_000 + ) }) it('rejects non-user targets and unknown ids without touching the gateway', async () => { @@ -697,11 +718,15 @@ describe('usePromptActions restoreToMessage', () => { userOrdinal: 0 }) - expect(requestGateway).toHaveBeenCalledWith('prompt.submit', { - session_id: RUNTIME_SESSION_ID, - text: 'first prompt', - truncate_before_user_ordinal: 0 - }) + expect(requestGateway).toHaveBeenCalledWith( + 'prompt.submit', + { + session_id: RUNTIME_SESSION_ID, + text: 'first prompt', + truncate_before_user_ordinal: 0 + }, + 1_800_000 + ) expect((lastState.messages as { id: string }[]).map(m => m.id)).toEqual(['u1']) }) }) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index e6f6b4bcaec..01d25c95d1a 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -2,7 +2,7 @@ import type { AppendMessage, ThreadMessage } from '@assistant-ui/react' import { useStore } from '@nanostores/react' import { type MutableRefObject, useCallback, useEffect, useRef } from 'react' -import { transcribeAudio } from '@/hermes' +import { transcribeAudio, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes' import { useI18n } from '@/i18n' import { stripAnsi } from '@/lib/ansi' import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages' @@ -159,7 +159,7 @@ interface PromptActionsOptions { createBackendSessionForSend: (preview?: string | null) => Promise handleSkinCommand: (arg: string) => string refreshSessions: () => Promise - requestGateway: (method: string, params?: Record) => Promise + requestGateway: (method: string, params?: Record, timeoutMs?: number) => Promise resumeStoredSession: (storedSessionId: string) => Promise | void selectedStoredSessionIdRef: MutableRefObject startFreshSessionDraft: () => void @@ -665,11 +665,15 @@ export function usePromptActions({ }) try { - await requestGateway('prompt.submit', { - session_id: activeSessionId, - text: userText, - truncate_before_user_ordinal: truncateBeforeUserOrdinal - }) + await requestGateway( + 'prompt.submit', + { + session_id: activeSessionId, + text: userText, + truncate_before_user_ordinal: truncateBeforeUserOrdinal + }, + PROMPT_SUBMIT_REQUEST_TIMEOUT_MS + ) } catch (err) { updateSessionState(activeSessionId, state => ({ ...state, @@ -702,11 +706,15 @@ export function usePromptActions({ } const submit = () => - requestGateway('prompt.submit', { - session_id: sessionId, - text, - ...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal }) - }) + requestGateway( + 'prompt.submit', + { + session_id: sessionId, + text, + ...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal }) + }, + PROMPT_SUBMIT_REQUEST_TIMEOUT_MS + ) if (interruptFirst) { await interrupt() diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts index 1975bf189b1..fba7eac8231 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts @@ -1,6 +1,7 @@ import { type MutableRefObject, useCallback } from 'react' import type { Translations } from '@/i18n' +import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes' import { type ChatMessage, textPart } from '@/lib/chat-messages' import { optimisticAttachmentRef } from '@/lib/chat-runtime' import { setMutableRef } from '@/lib/mutable-ref' @@ -252,7 +253,9 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { let submitErr: unknown = null try { - await withSessionBusyRetry(() => requestGateway('prompt.submit', { session_id: sessionId, text })) + await withSessionBusyRetry(() => + requestGateway('prompt.submit', { session_id: sessionId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS) + ) } catch (firstErr) { if (isSessionNotFoundError(firstErr) && selectedStoredSessionIdRef.current) { // Re-register the session in the gateway and get a fresh live ID. @@ -264,7 +267,9 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { if (recoveredId) { activeSessionIdRef.current = recoveredId - await withSessionBusyRetry(() => requestGateway('prompt.submit', { session_id: recoveredId, text })) + await withSessionBusyRetry(() => + requestGateway('prompt.submit', { session_id: recoveredId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS) + ) } else { submitErr = firstErr } diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts index d3533f4d688..8d8b462ef3e 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts @@ -6,7 +6,11 @@ import { type CommandsCatalogLike, filterDesktopCommandsCatalog } from '@/lib/de import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors' import type { ComposerAttachment } from '@/store/composer' -export type GatewayRequest = (method: string, params?: Record) => Promise +export type GatewayRequest = ( + method: string, + params?: Record, + timeoutMs?: number +) => Promise export function delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index aed4194ef33..5006ce417ed 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -49,6 +49,15 @@ import type { const DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS = 30_000 const SESSION_LIST_REQUEST_TIMEOUT_MS = 60_000 +// prompt.submit is effectively fire-and-forget: turn completion is signaled by +// stream / message.complete events, NOT by the RPC return. A long turn (MoA +// presets running references + aggregator in series, deep reasoning, large tool +// chains) can legitimately take minutes to ACK, so bounding the ack by the +// generic 30s default surfaces a false "request timed out" toast while the turn +// is still running and will succeed (issue #55024). Match the backend's +// agent-turn ceiling (agent.gateway_timeout = 1800s) so the ack timeout only +// ever fires when the turn itself would have been abandoned server-side. +export const PROMPT_SUBMIT_REQUEST_TIMEOUT_MS = 1_800_000 export type { ActionResponse,