Files
hermes-agent/apps/desktop/src/plugins/hermes-bots/plugin.mentions.test.ts
T

397 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The two composer contributions Bot Mode registers, driven through the real
* `plugin.register()`: @-mention completions and the mention middleware.
*
* Both read the roster IMPERATIVELY (a popover must answer per keystroke, and
* the middleware runs on submit), so both go through the query cache rather
* than the hook. `useRoster` keys its query on `[...ROSTER_KEY, connectionId]`
* — one entry per connection the window has been on — and these readers used
* to call `getQueryData(ROSTER_KEY)` with the BARE key. That is an exact-key
* match in TanStack Query, so it matched NOTHING: completions offered no
* roster handles, and a remote `@name-device` mention passed through the
* middleware unhandled (the local `profiles.list` fallback only knows bare
* local names). Reproduced on a two-source install where the same profile
* name exists locally and on an SSH connection ("Vera"): typing
* `@default-vera` did nothing.
*
* The middleware is IDENTIFICATION-ONLY by design. It annotates who a tag
* resolves to and hands the agent a `message_agent` target; the renderer
* composes no CLI handoff and delivers nothing itself.
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RosterRow } from './types'
interface MentionCompletionItem {
display: string
insert: string
meta: string
}
interface ComposerDraft {
text: string
}
interface Contribution {
area: string
data: {
handler?: (draft: ComposerDraft) => Promise<ComposerDraft>
provide?: (query: string) => MentionCompletionItem[]
}
id: string
}
/** One profile name on two sources: locally and on the SSH connection "vera".
* The union roster disambiguates the remote row as @default-vera. */
const ROSTER = {
profiles: [
{ connectionId: 'local', connectionKind: 'local', connectionLabel: 'This device', name: 'default' },
{
connectionId: 'vera',
connectionKind: 'ssh',
connectionLabel: 'Vera',
handle: 'default-vera',
name: 'default',
remoteSource: true,
sourceScoped: true
}
]
}
const { cache, hostMock, live } = vi.hoisted(() => {
const live = { focused: 'default', profile: 'default', stored: null as string | null }
return {
cache: new Map<string, { key: unknown[]; value: unknown }>(),
hostMock: {
notify: vi.fn(),
request: vi.fn(async () => ({})),
requestProfile: vi.fn(async () => ({})),
state: {
connectionId: { get: () => 'local', listen: () => () => undefined },
focusedSessionProfile: { get: () => live.focused, listen: () => () => undefined },
focusedStoredSessionId: { get: () => live.stored, listen: () => () => undefined },
gateway: { get: () => null, listen: () => () => undefined },
profile: { get: () => live.profile, listen: () => () => undefined }
}
},
live
}
})
vi.mock('@hermes/plugin-sdk', async () => {
const { atom } = await import('nanostores')
const stub: unknown = new Proxy(function stubbed() {}, {
apply: () => stub,
get: (_target, key) => (key === 'then' ? undefined : stub)
})
// A faithful-enough TanStack Query v5 cache: getQueryData matches ONLY the
// exact key; getQueriesData prefix-matches a key family.
const keyOf = (key: unknown[]) => JSON.stringify(key)
const queryClient = {
getQueriesData: ({ queryKey }: { queryKey: unknown[] }) =>
[...cache.values()]
.filter(entry => queryKey.every((part, index) => entry.key[index] === part))
.map(entry => [entry.key, entry.value]),
getQueryData: (key: unknown[]) => cache.get(keyOf(key))?.value,
invalidateQueries: () => undefined,
setQueryData: (key: unknown[], value: unknown) => cache.set(keyOf(key), { key, value })
}
const known: Record<string, unknown> = {
atom,
COMPOSER_AREAS: { atCompletions: 'composer.atCompletions', middleware: 'composer.middleware' },
host: hostMock,
PALETTE_AREA: 'palette',
queryClient
}
return new Proxy(known, {
get: (target, key) =>
typeof key === 'symbol' || key in target ? target[key as string] : key === 'then' ? undefined : stub,
has: () => true
})
})
interface Fixture {
/** Which connection id the cache entry is filed under — 'local' is the one
* the live window would write. */
cacheKeyConnection?: string
/** Profile owning the chat on screen; a bot never @s itself. */
focused?: string
profiles?: Array<Record<string, unknown>>
}
/** Register the plugin and hand back its composer contributions. */
async function contributions({
cacheKeyConnection = 'local',
focused = 'default',
profiles = ROSTER.profiles
}: Fixture = {}) {
vi.resetModules()
cache.clear()
live.focused = focused
// Exactly where useRoster writes it: suffixed with the connection id.
cache.set(JSON.stringify(['hermes-bots', 'roster', cacheKeyConnection]), {
key: ['hermes-bots', 'roster', cacheKeyConnection],
value: { profiles }
})
const plugin = (await import('./plugin')).default
const registered: Contribution[] = []
try {
plugin.register({
i18n: { register: () => () => undefined },
onDispose: () => undefined,
register: (contribution: Contribution) => registered.push(contribution),
storage: { get: async () => undefined, remove: async () => undefined, set: async () => undefined }
} as never)
} catch {
// Registration walks UI surfaces the stub does not fully model; the
// contributions registered before any throw are what these tests drive.
}
const completions = registered.find(entry => entry.id === 'mention-completions')
const middleware = registered.find(entry => entry.id === 'mention-middleware')
expect(completions?.data.provide).toBeTypeOf('function')
expect(middleware?.data.handler).toBeTypeOf('function')
return { handler: middleware!.data.handler!, provide: completions!.data.provide! }
}
// The plugin entry pulls in every Bot Mode surface; transforming that graph
// once costs more than a single test's budget.
beforeAll(async () => {
await import('./plugin')
}, 120_000)
beforeEach(() => {
vi.clearAllMocks()
live.focused = 'default'
live.profile = 'default'
live.stored = null
})
describe('Bot Chat reset guard', () => {
it.each([
{ connectionId: 'local', sourceScoped: true },
{ connectionId: 'remote', remoteSource: true, sourceScoped: true },
{}
])('compacts only the selected bot canonical chat: %j', async source => {
const { handler } = await contributions()
const { $lastRoster } = await import('./data')
const { saveSelectedRosterBot } = await import('./bot-state')
const selected: RosterRow = {
...source,
name: 'writer',
canonical_session: { id: 'bot-root', resolved_id: 'bot-tip' }
}
// An identically named bot on another connection must not win the lookup.
$lastRoster.set([
{ name: 'writer', connectionId: 'other', sourceScoped: true, canonical_session: { id: 'other-chat' } },
selected
])
saveSelectedRosterBot(selected)
for (const stored of ['bot-root', 'bot-tip']) {
live.stored = stored
for (const text of ['/new', '/reset']) {
hostMock.notify.mockClear()
expect(await handler({ text })).toEqual({ text: '/compact' })
expect(hostMock.notify).toHaveBeenCalledOnce()
expect(hostMock.notify).toHaveBeenCalledWith(expect.objectContaining({ title: 'This chat never resets' }))
}
}
for (const stored of ['scratch-chat', 'other-chat', null]) {
live.stored = stored
for (const text of ['/new', '/reset']) {
hostMock.notify.mockClear()
const draft = { text }
expect(await handler(draft)).toBe(draft)
expect(hostMock.notify).not.toHaveBeenCalled()
}
}
})
})
describe('@-mention completions', () => {
it('offers the remote @name-device handle from the suffixed cache entry', async () => {
const { provide } = await contributions()
expect(provide('').map(item => item.insert)).toContain('@default-vera')
})
it('still resolves when the only cached roster is under another connection id', async () => {
// The window moved connections; the stale entry is the only one cached.
const { provide } = await contributions({ cacheKeyConnection: 'vera' })
expect(provide('').map(item => item.insert)).toContain('@default-vera')
})
it('surfaces default as @hermes and prefix-filters on the handle', async () => {
const { provide } = await contributions({
focused: 'researcher',
profiles: [
{ name: 'default' },
{ name: 'researcher' },
{ connectionLabel: 'Homelab', handle: 'writer-homelab', name: 'writer' }
]
})
expect(provide('').map(item => item.insert)).toEqual(expect.arrayContaining(['@hermes', '@writer-homelab']))
expect(provide('wri').map(item => item.insert)).toEqual(['@writer-homelab'])
})
it('filters self by the FOCUSED chat owner, not the gateway socket profile', async () => {
// The socket stays on `default` while the user is inside another bot's
// chat; the bot in front of you must not be offered as a handoff target,
// and a renamed `default` must stay mentionable from there.
const { provide } = await contributions({
focused: 'renametest',
profiles: [{ display_name: 'Lucy', name: 'default' }, { name: 'renametest' }]
})
const inserts = provide('').map(item => item.insert)
expect(inserts).toContain('@lucy')
expect(inserts).not.toContain('@renametest')
})
it('yields nothing, and never throws, on a cold roster cache', async () => {
const { provide } = await contributions({ profiles: [] })
expect(provide('')).toEqual([])
})
it('never offers the bot whose chat you are already in', async () => {
const { provide } = await contributions()
// 'default' on the LIVE connection is the active bot; only the Vera twin
// is a real handoff target.
expect(provide('').map(item => item.insert)).toEqual(['@default-vera'])
})
it('narrows on the typed prefix and labels the source device', async () => {
const { provide } = await contributions()
expect(provide('default-v')[0]).toMatchObject({ insert: '@default-vera', meta: expect.stringContaining('Vera') })
expect(provide('zzz')).toEqual([])
})
})
describe('the mention middleware', () => {
it('identifies a remote @name-device mention without delivering anything', async () => {
const { handler } = await contributions()
const result = await handler({ text: '@default-vera what is the disk space on the server?' })
expect(result.text).toMatch(/@mentions resolved from the Bot Mode roster/)
expect(result.text).toMatch(/@default-vera/)
// No CLI handoff is composed and the renderer performs NO delivery — the
// agent owns messaging via its message_agent tool.
expect(result.text).not.toMatch(/hermes -p '?default/)
expect(hostMock.requestProfile).not.toHaveBeenCalled()
})
it('hands the agent a relay-resolvable canonical target', async () => {
const { handler } = await contributions()
const result = await handler({ text: 'ping @default-vera' })
// The UI alias ('default-vera') is not a relay identity: resolve_remote_target()
// accepts only a roster row's handle/profile, optionally @connection-qualified.
// The annotation must carry the canonical profile@connection form (#97678).
expect(result.text).toMatch(/message_agent target: "default@vera"/)
expect(result.text).not.toMatch(/message_agent target: "default-vera/)
expect(result.text).toMatch(/on Vera/)
})
it('annotates the resolvable handle for a local row whose UI alias differs', async () => {
// The reporter's shape (#97678 / Discord video): the LOCAL twin carries
// the 'default-this-device' alias when the remote gateway is active.
// The local resolver only knows bare profile names / 'hermes'.
const { handler } = await contributions({
focused: 'ops',
profiles: [
{ connectionId: 'local', connectionKind: 'local', handle: 'default-this-device', name: 'default' },
{ name: 'ops' }
]
})
const result = await handler({ text: 'ping @default-this-device' })
expect(result.text).toMatch(/@default-this-device = agent profile "default"/)
expect(result.text).toMatch(/message_agent target: "hermes"/)
expect(result.text).not.toMatch(/message_agent target: "default-this-device/)
})
it('passes a draft with no mention straight through', async () => {
const { handler } = await contributions()
const draft = { text: 'no tags here' }
expect(await handler(draft)).toBe(draft)
})
it('leaves an unresolvable tag alone rather than annotating a guess', async () => {
const { handler } = await contributions()
const draft = { text: 'hey @nobody' }
expect((await handler(draft)).text).toBe('hey @nobody')
})
it('names a local bot without inventing a device', async () => {
const { handler } = await contributions({ focused: 'research', profiles: [{ name: 'research' }, { name: 'ops' }] })
const result = await handler({ text: 'please @ops review the diff' })
expect(result.text).toMatch(/@ops = agent profile "ops"/)
expect(result.text).toMatch(/message_agent/)
expect(result.text).not.toMatch(/ — on /)
})
it('teaches no shellout and forbids forwarding the user’s text verbatim', async () => {
// The class behind #91397 / #91304 / #91339: the renderer used to compose
// a `hermes -p …` handoff, giving the model a second send path and a way
// to relay the raw draft.
const { handler } = await contributions({ focused: 'research', profiles: [{ name: 'research' }, { name: 'ops' }] })
const result = await handler({ text: 'ask @ops to summarize' })
expect(result.text).not.toMatch(/hermes -p/)
expect(result.text).not.toMatch(/terminal call/i)
expect(result.text).not.toMatch(/background=true/)
expect(result.text).toMatch(/compose your own message/i)
expect(result.text).toMatch(/never forward/i)
})
it('keeps a poisoned bot title inert prose', async () => {
// Nothing here is a command line, so there is nothing to break out of —
// the invariant that matters is that no hermes command is ever emitted.
const { handler } = await contributions({
focused: 'ops',
profiles: [
{ name: 'ops' },
{ display_name: 'Evil" ; touch /tmp/pwned ; echo "$(touch /tmp/pwned2)"', name: 'research' }
]
})
const result = await handler({ text: 'ping @research please' })
expect(result.text).not.toMatch(/`hermes/)
})
it('ignores an email address', async () => {
const { handler } = await contributions({ focused: 'research', profiles: [{ name: 'research' }, { name: 'ops' }] })
const untouched = 'mail user@example.com and ping @nosuchbot'
expect((await handler({ text: untouched })).text).toBe(untouched)
})
})