mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 13:26:40 +00:00
fix(desktop): a catalog link that launches Desktop opens the install dialog
A `hermes://plugin/install?catalog=` link that launches a closed Desktop is delivered as soon as the renderer mounts its deep-link listener. That is before the backend socket exists, so the lookup failed before anything reached the backend, and no dialog appeared. On main the renderer fetched the docs JSON instead, which needs no backend, so cold start worked there. The lookup now waits for the active gateway to open, within the renderer's existing cold-boot budget (`BACKEND_BOOT_WAIT_TIMEOUT_MS`, 180 s). The route lookup plus the RPC is also bounded as one call (`LOOKUP_TIMEOUT_MS`), because the route step has no deadline of its own. Past either bound the lookup reads as `unavailable`, so a startup link always ends in the dialog or a visible error. Live, dev Desktop from this branch, isolated home, link in argv: - Before: link delivered 17:02:55, gateway active 17:03:00; at 50 s no install request, no dialog. - After: link delivered 17:04:06, gateway active 17:04:10; the Install plugin dialog opened for hermes-memory-wiki at pin abec9e82. - Desktop running (second-instance link): fish-audio opens the dialog; `plugins.catalog.lookup` sent once, no plugins.json fetch. - Unknown name: "not in the Hermes plugin catalog" toast.
This commit is contained in:
1 parent
97c5f5b590
commit
c70332f888
2 files changed
+82
-3
No files matched your search
@@ -1,7 +1,13 @@
|
||||
import { JSON_RPC_METHOD_NOT_FOUND, JsonRpcGatewayError } from '@hermes/shared'
|
||||
import { type ConnectionState, JSON_RPC_METHOD_NOT_FOUND, JsonRpcGatewayError } from '@hermes/shared'
|
||||
import { atom } from 'nanostores'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { type CatalogLookupReply, type CatalogRequest, lookupPluginCatalogEntry } from './plugin-catalog'
|
||||
import {
|
||||
type CatalogLookupReply,
|
||||
type CatalogRequest,
|
||||
lookupPluginCatalogEntry,
|
||||
sendWhenGatewayOpen
|
||||
} from './plugin-catalog'
|
||||
|
||||
const SHA = 'a'.repeat(40)
|
||||
|
||||
@@ -81,3 +87,28 @@ describe('lookupPluginCatalogEntry', () => {
|
||||
expect(noFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sendWhenGatewayOpen', () => {
|
||||
it('holds a lookup that arrives before the backend connects, then sends it once the gateway opens', async () => {
|
||||
const state = atom<ConnectionState>('connecting')
|
||||
const send = vi.fn(async () => 'sent')
|
||||
const pending = sendWhenGatewayOpen(send, { state, timeoutMs: 5_000 })
|
||||
|
||||
await Promise.resolve()
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
|
||||
state.set('open')
|
||||
|
||||
await expect(pending).resolves.toBe('sent')
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects when the backend never connects, so a startup link still ends in a visible error', async () => {
|
||||
const send = vi.fn(async () => 'sent')
|
||||
|
||||
await expect(
|
||||
sendWhenGatewayOpen(send, { state: atom<ConnectionState>('connecting'), timeoutMs: 10 })
|
||||
).rejects.toThrow('Hermes backend did not connect')
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -19,8 +19,13 @@
|
||||
* Plugin dialog, which still requires the user's explicit confirmation.
|
||||
*/
|
||||
|
||||
import type { ConnectionState } from '@hermes/shared'
|
||||
import type { ReadableAtom } from 'nanostores'
|
||||
|
||||
import { isMissingRpcMethod } from '@/lib/gateway-rpc'
|
||||
import { BACKEND_BOOT_WAIT_TIMEOUT_MS, withTimeout } from '@/lib/with-timeout'
|
||||
import { activeGatewayProfileKey, requestGatewayForProfile } from '@/store/gateway'
|
||||
import { $gatewayState } from '@/store/session'
|
||||
|
||||
export const CATALOG_ORIGIN = 'https://hermes-agent.nousresearch.com'
|
||||
export const CATALOG_PICKER_URL = `${CATALOG_ORIGIN}/docs/plugins?embed=picker`
|
||||
@@ -78,8 +83,51 @@ export type CatalogRequest = (
|
||||
params: { name: string }
|
||||
) => Promise<CatalogLookupReply | null | undefined>
|
||||
|
||||
/**
|
||||
* Run `send` once the active gateway is open. A link that launches Desktop is
|
||||
* delivered as soon as the renderer mounts, before the backend socket exists,
|
||||
* so sending at once fails before anything reaches the backend. The wait shares
|
||||
* the renderer's cold-boot budget; past it the caller gets a rejection, never a
|
||||
* promise that stays pending.
|
||||
*/
|
||||
export function sendWhenGatewayOpen<T>(
|
||||
send: () => Promise<T>,
|
||||
{
|
||||
state = $gatewayState,
|
||||
timeoutMs = BACKEND_BOOT_WAIT_TIMEOUT_MS
|
||||
}: {
|
||||
state?: ReadableAtom<ConnectionState>
|
||||
timeoutMs?: number
|
||||
} = {}
|
||||
): Promise<T> {
|
||||
if (state.get() === 'open') {
|
||||
return send()
|
||||
}
|
||||
|
||||
let stop = () => {}
|
||||
|
||||
const opened = new Promise<void>(resolve => {
|
||||
stop = state.listen(next => {
|
||||
if (next === 'open') {
|
||||
stop()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return withTimeout(opened, timeoutMs, 'Hermes backend did not connect', () => stop()).then(send)
|
||||
}
|
||||
|
||||
// The route lookup inside requestGatewayForProfile has no deadline of its own, so the
|
||||
// whole call is bounded here, not just the RPC.
|
||||
const activeProfileRequest: CatalogRequest = (method, params) =>
|
||||
requestGatewayForProfile<CatalogLookupReply>(activeGatewayProfileKey(), method, params, LOOKUP_TIMEOUT_MS)
|
||||
sendWhenGatewayOpen(() =>
|
||||
withTimeout(
|
||||
requestGatewayForProfile<CatalogLookupReply>(activeGatewayProfileKey(), method, params, LOOKUP_TIMEOUT_MS),
|
||||
LOOKUP_TIMEOUT_MS,
|
||||
`request timed out: ${method}`
|
||||
)
|
||||
)
|
||||
|
||||
async function lookupFromFeed(name: string, fetchImpl: typeof fetch): Promise<PluginCatalogLookup> {
|
||||
let rows: unknown
|
||||
|
||||
Reference in new issue
Block a user