fix(desktop): don't false-timeout long prompt.submit turns (MoA, deep reasoning) (#56411)

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.
This commit is contained in:
Teknium 2026-07-01 06:33:47 -07:00 committed by GitHub
parent eae3700b16
commit 1641441837
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 93 additions and 42 deletions

View file

@ -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'])
})
})

View file

@ -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<string | null>
handleSkinCommand: (arg: string) => string
refreshSessions: () => Promise<void>
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
requestGateway: <T>(method: string, params?: Record<string, unknown>, timeoutMs?: number) => Promise<T>
resumeStoredSession: (storedSessionId: string) => Promise<void> | void
selectedStoredSessionIdRef: MutableRefObject<string | null>
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()

View file

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

View file

@ -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 = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
export type GatewayRequest = <T>(
method: string,
params?: Record<string, unknown>,
timeoutMs?: number
) => Promise<T>
export function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))

View file

@ -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,