mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-15 14:22:43 +00:00
Two client-side halves of the #55578 session split: 1. Submit with a null activeSessionId but a SELECTED stored session now resumes that stored session instead of falling straight through to createBackendSessionForSend - which silently forked the user's conversation into a brand-new session that then got orphan-reaped. New-chat drafts (no stored selection) still create sessions as before. 2. prompt.submit recovery now also fires on gateway request timeouts, not only 'session not found'. A starved backend loop (the async- delegation poller spin) rejects the submit with 'request timed out' even though the stored session is fine; previously that surfaced an error, left the binding cleared, and set up the split on the next send. Fail-then-pass: 2 new tests fail with production code reverted.
This commit is contained in:
parent
ae5e39005b
commit
8e734810df
3 changed files with 177 additions and 5 deletions
|
|
@ -59,7 +59,9 @@ function Harness({
|
|||
requestGateway,
|
||||
resumeStoredSession,
|
||||
seedMessages,
|
||||
storedSessionId
|
||||
storedSessionId,
|
||||
activeSessionId,
|
||||
createBackendSessionForSend
|
||||
}: {
|
||||
busyRef?: MutableRefObject<boolean>
|
||||
onReady: (handle: HarnessHandle) => void
|
||||
|
|
@ -70,8 +72,12 @@ function Harness({
|
|||
resumeStoredSession?: (storedSessionId: string) => Promise<void> | void
|
||||
seedMessages?: unknown[]
|
||||
storedSessionId?: null | string
|
||||
activeSessionId?: null | string
|
||||
createBackendSessionForSend?: () => Promise<null | string>
|
||||
}) {
|
||||
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
|
||||
const activeSessionIdRef: MutableRefObject<string | null> = {
|
||||
current: activeSessionId === undefined ? RUNTIME_SESSION_ID : activeSessionId
|
||||
}
|
||||
|
||||
const selectedStoredSessionIdRef: MutableRefObject<string | null> = {
|
||||
current: storedSessionId === undefined ? RUNTIME_SESSION_ID : storedSessionId
|
||||
|
|
@ -87,11 +93,11 @@ function Harness({
|
|||
} as never)
|
||||
|
||||
const actions = usePromptActions({
|
||||
activeSessionId: RUNTIME_SESSION_ID,
|
||||
activeSessionId: activeSessionId === undefined ? RUNTIME_SESSION_ID : activeSessionId,
|
||||
activeSessionIdRef,
|
||||
branchCurrentSession: async () => true,
|
||||
busyRef: localBusyRef,
|
||||
createBackendSessionForSend: async () => RUNTIME_SESSION_ID,
|
||||
createBackendSessionForSend: createBackendSessionForSend ?? (async () => RUNTIME_SESSION_ID),
|
||||
handleSkinCommand: () => '',
|
||||
openMemoryGraph: openMemoryGraph ?? (() => undefined),
|
||||
refreshSessions,
|
||||
|
|
@ -1190,6 +1196,124 @@ describe('usePromptActions sleep/wake session recovery', () => {
|
|||
expect(await handle!.submitText('message')).toBe(false)
|
||||
expect(calls).not.toContain('session.resume')
|
||||
})
|
||||
|
||||
it('recovers via session.resume when prompt.submit TIMES OUT and a stored session is selected (#55578)', async () => {
|
||||
// A starved gateway loop rejects with "request timed out: prompt.submit".
|
||||
// With a stored session selected, that must recover exactly like
|
||||
// "session not found" — resume + retry — not surface an error that leaves
|
||||
// activeSessionId null and lets the next send mint a new session.
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
let submitAttempts = 0
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
calls.push({ method, params })
|
||||
|
||||
if (method === 'prompt.submit') {
|
||||
submitAttempts += 1
|
||||
|
||||
if (submitAttempts === 1) {
|
||||
throw new Error('request timed out: prompt.submit')
|
||||
}
|
||||
|
||||
return {} as never
|
||||
}
|
||||
|
||||
if (method === 'session.resume') {
|
||||
return { session_id: RECOVERED_SESSION_ID } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
storedSessionId={STORED_SESSION_ID}
|
||||
/>
|
||||
)
|
||||
|
||||
const ok = await handle!.submitText('message during starved loop')
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit'])
|
||||
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID })
|
||||
expect(calls[2]?.params).toEqual({
|
||||
session_id: RECOVERED_SESSION_ID,
|
||||
text: 'message during starved loop'
|
||||
})
|
||||
})
|
||||
|
||||
it('resumes the SELECTED stored session instead of minting a new one when activeSessionId is null (#55578 split)', async () => {
|
||||
// The exact split path from #55578 symptom (b): the runtime binding is
|
||||
// gone (orphan-reaped / cleared by a timeout) but a stored session is
|
||||
// still selected in the sidebar. A follow-up submit must continue that
|
||||
// conversation via session.resume — createBackendSessionForSend would
|
||||
// silently fork the user's chat in two.
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
const createBackendSessionForSend = vi.fn(async () => 'brand-new-session-WRONG')
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
calls.push({ method, params })
|
||||
|
||||
if (method === 'session.resume') {
|
||||
return { session_id: RECOVERED_SESSION_ID } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
activeSessionId={null}
|
||||
createBackendSessionForSend={createBackendSessionForSend}
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
storedSessionId={STORED_SESSION_ID}
|
||||
/>
|
||||
)
|
||||
|
||||
const ok = await handle!.submitText('follow-up in the selected chat')
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(createBackendSessionForSend).not.toHaveBeenCalled()
|
||||
expect(calls.map(c => c.method)).toEqual(['session.resume', 'prompt.submit'])
|
||||
expect(calls[0]?.params).toEqual({ session_id: STORED_SESSION_ID })
|
||||
expect(calls[1]?.params).toMatchObject({ session_id: RECOVERED_SESSION_ID })
|
||||
})
|
||||
|
||||
it('still creates a new session for a genuine new-chat draft (no stored session selected)', async () => {
|
||||
const createBackendSessionForSend = vi.fn(async () => RUNTIME_SESSION_ID)
|
||||
const calls: string[] = []
|
||||
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
calls.push(method)
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
activeSessionId={null}
|
||||
createBackendSessionForSend={createBackendSessionForSend}
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
storedSessionId={null}
|
||||
/>
|
||||
)
|
||||
|
||||
const ok = await handle!.submitText('first message of a new chat')
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(createBackendSessionForSend).toHaveBeenCalledTimes(1)
|
||||
expect(calls).not.toContain('session.resume')
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePromptActions eager attachment upload (drop-time)', () => {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
inlineErrorMessage,
|
||||
isProviderSetupError,
|
||||
isSessionBusyError,
|
||||
isGatewayTimeoutError,
|
||||
isSessionNotFoundError,
|
||||
type SubmitTextOptions,
|
||||
withSessionBusyRetry
|
||||
|
|
@ -213,6 +214,34 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
|||
setMessages(current => [...current, buildUserMessage()])
|
||||
}
|
||||
|
||||
if (!sessionId && selectedStoredSessionIdRef.current) {
|
||||
// A stored session is SELECTED but its runtime binding is gone (the
|
||||
// live session was orphan-reaped, or a timeout/reconnect cleared
|
||||
// activeSessionId). Continuing the selected conversation must mean
|
||||
// resuming it — minting a brand-new backend session here silently
|
||||
// splits the user's chat in two (#55578 symptom b). Only fall through
|
||||
// to session creation when NO stored session is selected (a genuine
|
||||
// new-chat draft).
|
||||
try {
|
||||
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
|
||||
session_id: selectedStoredSessionIdRef.current
|
||||
})
|
||||
|
||||
if (resumed?.session_id) {
|
||||
sessionId = resumed.session_id
|
||||
activeSessionIdRef.current = sessionId
|
||||
}
|
||||
} catch {
|
||||
// Resume failed (session gone from state.db, gateway hiccup) —
|
||||
// fall through to creating a fresh session rather than dead-ending
|
||||
// the user's message.
|
||||
}
|
||||
|
||||
if (sessionId) {
|
||||
seedOptimistic(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
try {
|
||||
sessionId = await createBackendSessionForSend(visibleText)
|
||||
|
|
@ -257,8 +286,15 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
|||
requestGateway('prompt.submit', { session_id: sessionId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS)
|
||||
)
|
||||
} catch (firstErr) {
|
||||
if (isSessionNotFoundError(firstErr) && selectedStoredSessionIdRef.current) {
|
||||
if (
|
||||
(isSessionNotFoundError(firstErr) || isGatewayTimeoutError(firstErr)) &&
|
||||
selectedStoredSessionIdRef.current
|
||||
) {
|
||||
// Re-register the session in the gateway and get a fresh live ID.
|
||||
// Timeouts recover the same way as "session not found": a starved
|
||||
// backend loop (#55578 symptom d) rejects the submit even though
|
||||
// the stored session is fine — resume + retry instead of erroring
|
||||
// out and losing the session binding.
|
||||
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
|
||||
session_id: selectedStoredSessionIdRef.current,
|
||||
source: 'desktop'
|
||||
|
|
|
|||
|
|
@ -52,6 +52,18 @@ export function isSessionNotFoundError(error: unknown): boolean {
|
|||
return /session not found/i.test(message)
|
||||
}
|
||||
|
||||
// Gateway JSON-RPC calls reject with "request timed out: <method>" when the
|
||||
// backend event loop is starved (e.g. a poller spin or a heavy async-injected
|
||||
// turn). For prompt.submit this is indistinguishable from a dead runtime
|
||||
// session on the client side — recovery must treat it like one (#55578):
|
||||
// resume the SELECTED stored session and retry, instead of surfacing an error
|
||||
// that leads to a null activeSessionId and a silently minted new session.
|
||||
export function isGatewayTimeoutError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
return /request timed out/i.test(message)
|
||||
}
|
||||
|
||||
// The gateway refuses prompt.submit while a turn is running (4009 "session
|
||||
// busy"). It's a transient concurrency guard, never a user-facing error: a
|
||||
// submit racing the settle edge (or a rewind interrupting mid-turn) just waits
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue