mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(desktop): keep queued drains out of the foreground session on session switch
A queue drain pairs two identifiers from different clocks: the queue key (flips with the route) and the explicit runtime id (lags a resume behind). Mid-switch the composer can fire a drain with storedSessionId=B but sessionId=A-runtime, and prompt.submit then lands B's queued prompt — and its whole answer turn — inside session A. Make the central runtime binding authoritative for queued sends: when the explicit runtime id no longer matches the binding recorded for the target stored session, adopt the binding (or drop to the stored-id resume path when none exists yet). The identity pair (storedSessionId === sessionId) is the fresh-chat fallback and stays untouched. Tests: re-home-via-resume and rebind-to-central-runtime; existing background-drain and sleep/wake cases declare central bindings explicitly. Null-fallback guard already on main; this PR is the remaining half.
This commit is contained in:
parent
95d303138b
commit
8879b9e9b2
2 changed files with 128 additions and 0 deletions
|
|
@ -1625,6 +1625,7 @@ describe('usePromptActions submit / queue drain semantics', () => {
|
|||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
getRuntimeIdForStoredSession={storedId => (storedId === 'stored-session-a' ? 'rt-session-a' : null)}
|
||||
onReady={h => (handle = h)}
|
||||
onUpdateState={(sessionId, storedSessionId, state) => updates.push({ sessionId, state, storedSessionId })}
|
||||
refreshSessions={async () => undefined}
|
||||
|
|
@ -1656,6 +1657,102 @@ describe('usePromptActions submit / queue drain semantics', () => {
|
|||
expect($busy.get()).toBe(false)
|
||||
})
|
||||
|
||||
it('a fromQueue drain carrying a stale runtime id re-homes via session.resume instead of landing in the foreground session', async () => {
|
||||
// The session-switch window this guards: the composer's queue key has
|
||||
// already flipped to session B (route-driven) while the foreground runtime
|
||||
// id prop still reads session A (resume-driven, one settle behind). Without
|
||||
// the central-binding check, prompt.submit fires with session_id=A and B's
|
||||
// queued prompt — plus its whole answer turn — lands inside A. With no
|
||||
// binding recorded for B yet, the stale id must be dropped and the drain
|
||||
// re-homed through the stored-session resume path.
|
||||
const updates: { sessionId: string; state: Record<string, unknown>; storedSessionId: null | string | undefined }[] =
|
||||
[]
|
||||
|
||||
const requestGateway = vi.fn(
|
||||
async (method: string) => (method === 'session.resume' ? { session_id: 'rt-session-b' } : {}) as never
|
||||
)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
onUpdateState={(sessionId, storedSessionId, state) => updates.push({ sessionId, state, storedSessionId })}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
|
||||
const accepted = await handle!.submitText('queued for B mid-switch', {
|
||||
fromQueue: true,
|
||||
sessionId: 'rt-session-a',
|
||||
storedSessionId: 'stored-session-b'
|
||||
})
|
||||
|
||||
expect(accepted).toBe(true)
|
||||
expect(requestGateway).toHaveBeenCalledWith('session.resume', {
|
||||
session_id: 'stored-session-b',
|
||||
source: 'desktop'
|
||||
})
|
||||
expect(requestGateway).toHaveBeenCalledWith(
|
||||
'prompt.submit',
|
||||
{
|
||||
session_id: 'rt-session-b',
|
||||
text: 'queued for B mid-switch'
|
||||
},
|
||||
1_800_000
|
||||
)
|
||||
// The invariant: the stale foreground runtime never receives the prompt.
|
||||
expect(
|
||||
requestGateway.mock.calls.every(
|
||||
([method, params]) =>
|
||||
method !== 'prompt.submit' || (params as { session_id?: string }).session_id !== 'rt-session-a'
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
updates.some(update => update.sessionId === 'rt-session-b' && update.storedSessionId === 'stored-session-b')
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('a fromQueue drain rebinds to the centrally recorded runtime when its explicit id is stale', async () => {
|
||||
// Same window, but B's runtime binding is already known centrally — the
|
||||
// drain should adopt the authoritative binding directly (no resume
|
||||
// round-trip) rather than trusting the leftover foreground id.
|
||||
const requestGateway = vi.fn(async () => ({}) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
getRuntimeIdForStoredSession={storedId => (storedId === 'stored-session-b' ? 'rt-session-b-live' : null)}
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
|
||||
const accepted = await handle!.submitText('queued for B, B already re-bound', {
|
||||
fromQueue: true,
|
||||
sessionId: 'rt-session-a',
|
||||
storedSessionId: 'stored-session-b'
|
||||
})
|
||||
|
||||
expect(accepted).toBe(true)
|
||||
expect(requestGateway).toHaveBeenCalledWith(
|
||||
'prompt.submit',
|
||||
{
|
||||
session_id: 'rt-session-b-live',
|
||||
text: 'queued for B, B already re-bound'
|
||||
},
|
||||
1_800_000
|
||||
)
|
||||
expect(requestGateway).not.toHaveBeenCalledWith('session.resume', expect.anything())
|
||||
expect(
|
||||
requestGateway.mock.calls.every(
|
||||
([method, params]) =>
|
||||
method !== 'prompt.submit' || (params as { session_id?: string }).session_id !== 'rt-session-a'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('a fromQueue drain with null runtime id does NOT land in the foreground session (cross-session leak guard)', async () => {
|
||||
// The cross-session leak: a background drain fires with sessionId=null
|
||||
// (the stored session's runtime was reaped by the gateway). Without the
|
||||
|
|
@ -2619,6 +2716,13 @@ describe('usePromptActions sleep/wake session recovery', () => {
|
|||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
// The central binding is stale in lockstep with the caller here: the
|
||||
// sleep/wake reaper only clears the GATEWAY's in-memory session, so
|
||||
// client-side state still swears by the old runtime id. That is what
|
||||
// routes this case to the reactive 404→resume→retry path instead of
|
||||
// the proactive binding check (covered by the cross-session drain
|
||||
// tests above).
|
||||
getRuntimeIdForStoredSession={storedId => (storedId === STORED_SESSION_ID ? 'rt-background-stale' : null)}
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
|
|
|
|||
|
|
@ -198,6 +198,30 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
|||
|
||||
let sessionId: null | string = options?.sessionId ?? (isBackgroundQueueDrain ? null : activeSessionIdRef.current)
|
||||
|
||||
// An explicit queued runtime id is authoritative ONLY while it still
|
||||
// belongs to its stored session. On a session switch the composer's
|
||||
// queue key flips with the route while the foreground runtime id lags a
|
||||
// resume behind, so a drain can fire with storedSessionId=B but
|
||||
// sessionId=A-runtime — and the prompt.submit below would land B's
|
||||
// queued prompt (and its whole answer turn) inside A. Verify the pair
|
||||
// against the central binding and drop a stale explicit id: the
|
||||
// targetStoredSessionId resume path below then rebinds the right
|
||||
// runtime, exactly as a background drain with an unknown binding does.
|
||||
// The identity pair (storedSessionId === sessionId) is the fresh-chat
|
||||
// fallback — an unpersisted conversation's queue key IS its runtime id,
|
||||
// so it has no central binding to check against and is left untouched.
|
||||
if (
|
||||
options?.sessionId &&
|
||||
options?.storedSessionId &&
|
||||
options.storedSessionId !== options.sessionId
|
||||
) {
|
||||
const boundRuntimeId = getRuntimeIdForStoredSession(options.storedSessionId)
|
||||
|
||||
if (boundRuntimeId !== options.sessionId) {
|
||||
sessionId = boundRuntimeId
|
||||
}
|
||||
}
|
||||
|
||||
// Pin the foreground session context for the whole async submit pipeline.
|
||||
// Without this, a fast session switch during session.resume / file.attach
|
||||
// can redirect the user's text into a different chat (#54527). Mutable —
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue