mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
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:
parent
eae3700b16
commit
1641441837
5 changed files with 93 additions and 42 deletions
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue