mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
A prompt typed mid-turn ("ghost bubble") could stick forever and never
send when the backend restarted/reconnected during the turn. Two fragile
assumptions in the composer queue drain caused it:
1. Drain fired ONLY on an observed busy true→false edge. A remount/
reconnect resets `previousBusyRef` to the current busy value, so the
settle edge is swallowed and the queue never drains. Replace
`shouldAutoDrainOnSettle` with the edge-independent `shouldAutoDrain`
(idle + non-empty), driven on the settle edge, on mount/reconnect, and
after a re-key. The drain lock still serializes sends.
2. The queue is keyed by `queueSessionKey || sessionId`. When a backend
resume mints a new runtime session id for the same conversation, the
entry strands under the dead key. Pass the *stable* stored id as
`queueSessionKey` so the composer can tell runtime churn from a real
session switch, and `migrateQueuedPrompts` re-keys pending entries on a
runtime-id change only (never on a deliberate switch).
Also make the drain resilient to a thrown/rejected onSubmit (e.g. a stale-
session 404): the entry stays queued and is retried on the next idle, with
a per-entry attempt cap (MAX_AUTO_DRAIN_ATTEMPTS) to avoid spin-loops and a
quiet toast once it gives up. A manual send clears the backoff.
Tests: composer-queue covers edge-free drain + re-key migration;
use-prompt-actions covers rejected-drain-keeps-entry + idle retry sends.
170 lines
6.2 KiB
TypeScript
170 lines
6.2 KiB
TypeScript
import { beforeEach, describe, expect, it } from 'vitest'
|
|
|
|
import type { ComposerAttachment } from './composer'
|
|
import {
|
|
$queuedPromptsBySession,
|
|
clearQueuedPrompts,
|
|
dequeueQueuedPrompt,
|
|
enqueueQueuedPrompt,
|
|
getQueuedPrompts,
|
|
migrateQueuedPrompts,
|
|
promoteQueuedPrompt,
|
|
removeQueuedPrompt,
|
|
shouldAutoDrain,
|
|
updateQueuedPrompt,
|
|
updateQueuedPromptText
|
|
} from './composer-queue'
|
|
|
|
const SESSION_KEY = 'session-abc'
|
|
const QUEUE_STORAGE_KEY = 'hermes.desktop.composerQueue.v1'
|
|
|
|
function attachment(id: string, kind: ComposerAttachment['kind'] = 'file'): ComposerAttachment {
|
|
return {
|
|
id,
|
|
kind,
|
|
label: id,
|
|
refText: `@file:${id}`
|
|
}
|
|
}
|
|
|
|
describe('composer queue store', () => {
|
|
beforeEach(() => {
|
|
window.localStorage.removeItem(QUEUE_STORAGE_KEY)
|
|
$queuedPromptsBySession.set({})
|
|
})
|
|
|
|
it('queues prompts in FIFO order', () => {
|
|
enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'first' })
|
|
enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'second' })
|
|
|
|
expect(dequeueQueuedPrompt(SESSION_KEY)?.text).toBe('first')
|
|
expect(dequeueQueuedPrompt(SESSION_KEY)?.text).toBe('second')
|
|
expect(dequeueQueuedPrompt(SESSION_KEY)).toBeNull()
|
|
})
|
|
|
|
it('clones attachments when queueing', () => {
|
|
const source = [attachment('a-1')]
|
|
const queued = enqueueQueuedPrompt(SESSION_KEY, { attachments: source, text: 'check clones' })
|
|
|
|
expect(queued).not.toBeNull()
|
|
expect(getQueuedPrompts(SESSION_KEY)[0]?.attachments[0]).toEqual(source[0])
|
|
expect(getQueuedPrompts(SESSION_KEY)[0]?.attachments[0]).not.toBe(source[0])
|
|
})
|
|
|
|
it('updates and removes queued entries by id', () => {
|
|
const first = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'draft one' })
|
|
const second = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'draft two' })
|
|
|
|
expect(first).not.toBeNull()
|
|
expect(second).not.toBeNull()
|
|
|
|
expect(updateQueuedPromptText(SESSION_KEY, first!.id, 'draft one edited')).toBe(true)
|
|
expect(getQueuedPrompts(SESSION_KEY).map(entry => entry.text)).toEqual(['draft one edited', 'draft two'])
|
|
|
|
expect(removeQueuedPrompt(SESSION_KEY, first!.id)).toBe(true)
|
|
expect(getQueuedPrompts(SESSION_KEY).map(entry => entry.text)).toEqual(['draft two'])
|
|
})
|
|
|
|
it('promotes a queued entry to the front', () => {
|
|
const first = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'first' })
|
|
const second = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'second' })
|
|
const third = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'third' })
|
|
|
|
expect(first).not.toBeNull()
|
|
expect(second).not.toBeNull()
|
|
expect(third).not.toBeNull()
|
|
|
|
expect(promoteQueuedPrompt(SESSION_KEY, third!.id)).toBe(true)
|
|
expect(getQueuedPrompts(SESSION_KEY).map(entry => entry.text)).toEqual(['third', 'first', 'second'])
|
|
expect(promoteQueuedPrompt(SESSION_KEY, third!.id)).toBe(false)
|
|
})
|
|
|
|
it('updates queued text and attachment snapshot', () => {
|
|
const first = enqueueQueuedPrompt(SESSION_KEY, { attachments: [attachment('f-1')], text: 'draft one' })
|
|
const editedAttachments = [attachment('f-2'), attachment('f-3', 'image')]
|
|
|
|
expect(first).not.toBeNull()
|
|
expect(
|
|
updateQueuedPrompt(SESSION_KEY, first!.id, {
|
|
attachments: editedAttachments,
|
|
text: 'edited text'
|
|
})
|
|
).toBe(true)
|
|
|
|
const queue = getQueuedPrompts(SESSION_KEY)
|
|
expect(queue[0]?.text).toBe('edited text')
|
|
expect(queue[0]?.attachments).toEqual(editedAttachments)
|
|
expect(queue[0]?.attachments[0]).not.toBe(editedAttachments[0])
|
|
})
|
|
|
|
it('clears queue state for a session', () => {
|
|
enqueueQueuedPrompt(SESSION_KEY, { attachments: [attachment('img-1', 'image')], text: 'queued' })
|
|
|
|
clearQueuedPrompts(SESSION_KEY)
|
|
|
|
expect(getQueuedPrompts(SESSION_KEY)).toEqual([])
|
|
expect($queuedPromptsBySession.get()[SESSION_KEY]).toBeUndefined()
|
|
expect(window.localStorage.getItem(QUEUE_STORAGE_KEY)).toBeNull()
|
|
})
|
|
|
|
it('persists queue entries into local storage', () => {
|
|
enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'persist me' })
|
|
|
|
const raw = window.localStorage.getItem(QUEUE_STORAGE_KEY)
|
|
expect(raw).toBeTruthy()
|
|
|
|
const parsed = JSON.parse(String(raw)) as Record<string, { text: string }[]>
|
|
expect(parsed[SESSION_KEY]?.[0]?.text).toBe('persist me')
|
|
})
|
|
})
|
|
|
|
describe('migrateQueuedPrompts', () => {
|
|
beforeEach(() => {
|
|
window.localStorage.removeItem(QUEUE_STORAGE_KEY)
|
|
$queuedPromptsBySession.set({})
|
|
})
|
|
|
|
it('moves entries from a dead runtime key onto the live one', () => {
|
|
enqueueQueuedPrompt('rt-old', { attachments: [], text: 'stranded' })
|
|
|
|
expect(migrateQueuedPrompts('rt-old', 'rt-new')).toBe(true)
|
|
expect(getQueuedPrompts('rt-old')).toEqual([])
|
|
expect(getQueuedPrompts('rt-new').map(e => e.text)).toEqual(['stranded'])
|
|
// The dead key is dropped from the store entirely.
|
|
expect($queuedPromptsBySession.get()['rt-old']).toBeUndefined()
|
|
})
|
|
|
|
it('appends after existing target entries (FIFO preserved)', () => {
|
|
enqueueQueuedPrompt('rt-new', { attachments: [], text: 'already here' })
|
|
enqueueQueuedPrompt('rt-old', { attachments: [], text: 'migrated' })
|
|
|
|
migrateQueuedPrompts('rt-old', 'rt-new')
|
|
|
|
expect(getQueuedPrompts('rt-new').map(e => e.text)).toEqual(['already here', 'migrated'])
|
|
})
|
|
|
|
it('is a no-op when source is empty or keys match', () => {
|
|
expect(migrateQueuedPrompts('rt-old', 'rt-new')).toBe(false)
|
|
expect(migrateQueuedPrompts('rt-x', 'rt-x')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('shouldAutoDrain', () => {
|
|
it('drains whenever idle with a non-empty queue', () => {
|
|
expect(shouldAutoDrain({ isBusy: false, queueLength: 1 })).toBe(true)
|
|
})
|
|
|
|
it('drains on mount/reconnect with no observed busy edge', () => {
|
|
// The whole point of dropping the edge: a remount resets the busy ref, so an
|
|
// edge-gated drain would strand the entry. Idle + non-empty must still fire.
|
|
expect(shouldAutoDrain({ isBusy: false, queueLength: 2 })).toBe(true)
|
|
})
|
|
|
|
it('does not drain mid-turn', () => {
|
|
expect(shouldAutoDrain({ isBusy: true, queueLength: 1 })).toBe(false)
|
|
})
|
|
|
|
it('does not drain an empty queue', () => {
|
|
expect(shouldAutoDrain({ isBusy: false, queueLength: 0 })).toBe(false)
|
|
})
|
|
})
|