mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
test(desktop): cover the directive-scope contract
Nine cases against the real hook and a real contentEditable: the scope surviving Tab-descend, Backspace climbing the path then dropping the scope whole, a mid-message pick keeping its trailing prose, a paste consuming an open scope, and the guards that keep @teknium1: / localhost:8080 from being mistaken for a directive. text-utils.test.ts picks up the additive `value` field and asserts the scope/value split directly.
This commit is contained in:
parent
cf5b6feae8
commit
1f72da4f88
2 changed files with 240 additions and 21 deletions
164
apps/desktop/src/app/chat/composer/directive-scope.test.ts
Normal file
164
apps/desktop/src/app/chat/composer/directive-scope.test.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import type { Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useComposerTrigger } from './hooks/use-composer-trigger'
|
||||
import { pathifyRefs } from './path-refs'
|
||||
import { composerPlainText, insertComposerContentsAtCaret, RICH_INPUT_SLOT } from './rich-editor'
|
||||
import { detectTrigger, openDirectiveScope, textBeforeCaret } from './text-utils'
|
||||
import { linkifyUrls } from './url-refs'
|
||||
|
||||
function folderItem(rel: string): Unstable_TriggerItem {
|
||||
const rawText = `@folder:${rel}/`
|
||||
|
||||
return {
|
||||
id: `${rawText}|0`,
|
||||
type: 'folder',
|
||||
label: rel.split('/').filter(Boolean).pop() ?? rel,
|
||||
metadata: { icon: 'folder', display: `${rel}/`, meta: 'dir', rawText, insertId: `${rel}/` }
|
||||
}
|
||||
}
|
||||
|
||||
/** Literally-typed text, caret `fromEnd` characters before the end. */
|
||||
function typed(text: string, fromEnd = 0) {
|
||||
const editor = document.createElement('div')
|
||||
|
||||
editor.contentEditable = 'true'
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
document.body.append(editor)
|
||||
|
||||
const node = document.createTextNode(text)
|
||||
|
||||
editor.append(node)
|
||||
|
||||
const range = document.createRange()
|
||||
|
||||
range.setStart(node, text.length - fromEnd)
|
||||
range.collapse(true)
|
||||
|
||||
const sel = window.getSelection()
|
||||
|
||||
sel?.removeAllRanges()
|
||||
sel?.addRange(range)
|
||||
|
||||
return editor
|
||||
}
|
||||
|
||||
function withTrigger(editor: HTMLDivElement, draft: string) {
|
||||
const editorRef = { current: editor as HTMLDivElement | null }
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useComposerTrigger({
|
||||
at: { adapter: null, loading: false },
|
||||
draftRef: { current: draft },
|
||||
editorRef,
|
||||
requestMainFocus: vi.fn(),
|
||||
setComposerText: vi.fn(),
|
||||
slash: { adapter: null, loading: false }
|
||||
})
|
||||
)
|
||||
|
||||
act(() => result.current.refreshTrigger())
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/** The composer's paste handler, minus the clipboard plumbing. */
|
||||
function paste(editor: HTMLDivElement, text: string) {
|
||||
insertComposerContentsAtCaret(editor, pathifyRefs(linkifyUrls(text)), openDirectiveScope(editor))
|
||||
}
|
||||
|
||||
describe('directive scope is a browse mode, not text to maintain', () => {
|
||||
it('Tab-descend carries the scope down instead of dropping to a bare path', () => {
|
||||
const editor = typed('@folder:apps/deskt')
|
||||
const result = withTrigger(editor, '@folder:apps/deskt')
|
||||
|
||||
expect(result.current.trigger).toMatchObject({ kind: '@', scope: 'folder', value: 'apps/deskt' })
|
||||
|
||||
act(() => result.current.replaceTriggerWithChip(folderItem('apps/desktop'), { descend: true }))
|
||||
|
||||
expect(composerPlainText(editor)).toBe('@folder:apps/desktop/')
|
||||
})
|
||||
|
||||
it('Backspace climbs the path, then drops the whole scope', () => {
|
||||
const editor = typed('@folder:apps/desktop/')
|
||||
const result = withTrigger(editor, '@folder:apps/desktop/')
|
||||
|
||||
act(() => result.current.ascendTriggerPath())
|
||||
expect(composerPlainText(editor)).toBe('@folder:apps/')
|
||||
|
||||
act(() => result.current.refreshTrigger())
|
||||
act(() => result.current.ascendTriggerPath())
|
||||
expect(composerPlainText(editor)).toBe('@folder:')
|
||||
|
||||
// The scope is one unit: Backspace drops it whole rather than nibbling
|
||||
// back through `:`, `r`, `e`, `d`, `l`, `o`, `f`.
|
||||
act(() => result.current.refreshTrigger())
|
||||
act(() => result.current.ascendTriggerPath())
|
||||
expect(composerPlainText(editor)).toBe('@')
|
||||
})
|
||||
|
||||
it('leaves Backspace alone when there is no scope and no path', () => {
|
||||
const editor = typed('@apps')
|
||||
const result = withTrigger(editor, '@apps')
|
||||
|
||||
let handled = true
|
||||
|
||||
act(() => {
|
||||
handled = result.current.ascendTriggerPath()
|
||||
})
|
||||
|
||||
expect(handled).toBe(false)
|
||||
})
|
||||
|
||||
it('a pick mid-message keeps the trailing prose and consumes the whole token', () => {
|
||||
const editor = typed('@folder:apps/deskt and some trailing words', 24)
|
||||
const result = withTrigger(editor, '@folder:apps/deskt and some trailing words')
|
||||
|
||||
act(() => result.current.replaceTriggerWithChip(folderItem('apps/desktop')))
|
||||
|
||||
expect(composerPlainText(editor)).toBe('@folder:`apps/desktop/` and some trailing words')
|
||||
expect(editor.querySelector('[data-ref-kind="folder"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('pasting into an open @url: scope consumes it instead of stacking', () => {
|
||||
const editor = typed('refer to @url:')
|
||||
|
||||
paste(editor, 'https://github.com/NousResearch/hermes-agent/pull/74533')
|
||||
|
||||
expect(composerPlainText(editor)).toBe('refer to @url:`https://github.com/NousResearch/hermes-agent/pull/74533`')
|
||||
expect(editor.textContent).not.toContain('@url:@url:')
|
||||
})
|
||||
|
||||
it('a normal paste with no open scope is untouched', () => {
|
||||
const editor = typed('look at ')
|
||||
|
||||
paste(editor, 'https://example.com/x')
|
||||
|
||||
expect(composerPlainText(editor)).toBe('look at @url:`https://example.com/x`')
|
||||
})
|
||||
|
||||
it('scope parsing leaves an unscoped @ query alone', () => {
|
||||
expect(detectTrigger('@apps/desk')).toMatchObject({ kind: '@', value: 'apps/desk' })
|
||||
expect(detectTrigger('@apps/desk')?.scope).toBeUndefined()
|
||||
})
|
||||
|
||||
it('openDirectiveScope only fires on an EMPTY scope', () => {
|
||||
// The count is what a paste consumes: `@url:` is 5 characters of syntax
|
||||
// the user never typed and shouldn't be left holding.
|
||||
expect(openDirectiveScope(typed('@url:'))).toBe(5)
|
||||
expect(openDirectiveScope(typed('@url:https://x.com'))).toBe(0)
|
||||
expect(openDirectiveScope(typed('plain text'))).toBe(0)
|
||||
})
|
||||
|
||||
it('chips stay atomic to scope detection', () => {
|
||||
const editor = typed('@folder:apps/desktop/')
|
||||
const result = withTrigger(editor, '@folder:apps/desktop/')
|
||||
|
||||
act(() => result.current.replaceTriggerWithChip(folderItem('apps/desktop')))
|
||||
|
||||
// A committed chip is one object-replacement char, so a fresh `@` typed
|
||||
// after it opens an unscoped browse rather than inheriting the old scope.
|
||||
expect(detectTrigger(`${textBeforeCaret(editor)}@`)?.scope).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -4,19 +4,19 @@ import { blobDedupeKey, detectTrigger, extractClipboardImageBlobs } from './text
|
|||
|
||||
describe('detectTrigger', () => {
|
||||
it('detects a bare slash trigger with an empty query', () => {
|
||||
expect(detectTrigger('/')).toEqual({ kind: '/', query: '', tokenLength: 1 })
|
||||
expect(detectTrigger('/')).toEqual({ kind: '/', query: '', tokenLength: 1, value: '' })
|
||||
})
|
||||
|
||||
it('detects a slash command query', () => {
|
||||
expect(detectTrigger('/skill')).toEqual({ kind: '/', query: 'skill', tokenLength: 6 })
|
||||
expect(detectTrigger('/skill')).toEqual({ kind: '/', query: 'skill', tokenLength: 6, value: 'skill' })
|
||||
})
|
||||
|
||||
it('detects a bare at-mention trigger with an empty query', () => {
|
||||
expect(detectTrigger('@')).toEqual({ kind: '@', query: '', tokenLength: 1 })
|
||||
expect(detectTrigger('@')).toEqual({ kind: '@', query: '', tokenLength: 1, value: '' })
|
||||
})
|
||||
|
||||
it('detects an at-mention query', () => {
|
||||
expect(detectTrigger('@file')).toEqual({ kind: '@', query: 'file', tokenLength: 5 })
|
||||
expect(detectTrigger('@file')).toEqual({ kind: '@', query: 'file', tokenLength: 5, value: 'file' })
|
||||
})
|
||||
|
||||
it('returns null for plain text', () => {
|
||||
|
|
@ -27,17 +27,20 @@ describe('detectTrigger', () => {
|
|||
expect(detectTrigger('/personality ')).toEqual({
|
||||
kind: '/',
|
||||
query: 'personality ',
|
||||
tokenLength: 13
|
||||
tokenLength: 13,
|
||||
value: 'personality '
|
||||
})
|
||||
expect(detectTrigger('/personality alic')).toEqual({
|
||||
kind: '/',
|
||||
query: 'personality alic',
|
||||
tokenLength: 17
|
||||
tokenLength: 17,
|
||||
value: 'personality alic'
|
||||
})
|
||||
expect(detectTrigger('/tools enable foo')).toEqual({
|
||||
kind: '/',
|
||||
query: 'tools enable foo',
|
||||
tokenLength: 17
|
||||
tokenLength: 17,
|
||||
value: 'tools enable foo'
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -54,14 +57,25 @@ describe('detectTrigger', () => {
|
|||
it('keeps the at-mention live while walking into subfolders', () => {
|
||||
// A `/` inside the query is path navigation, not the end of the token —
|
||||
// the popover has to stay open so the next directory level can load.
|
||||
expect(detectTrigger('@./')).toEqual({ kind: '@', query: './', tokenLength: 3 })
|
||||
expect(detectTrigger('@./src')).toEqual({ kind: '@', query: './src', tokenLength: 6 })
|
||||
expect(detectTrigger('@~/Desktop/')).toEqual({ kind: '@', query: '~/Desktop/', tokenLength: 11 })
|
||||
expect(detectTrigger('@/usr/local')).toEqual({ kind: '@', query: '/usr/local', tokenLength: 11 })
|
||||
expect(detectTrigger('@./')).toEqual({ kind: '@', query: './', tokenLength: 3, value: './' })
|
||||
expect(detectTrigger('@./src')).toEqual({ kind: '@', query: './src', tokenLength: 6, value: './src' })
|
||||
expect(detectTrigger('@~/Desktop/')).toEqual({
|
||||
kind: '@',
|
||||
query: '~/Desktop/',
|
||||
tokenLength: 11,
|
||||
value: '~/Desktop/'
|
||||
})
|
||||
expect(detectTrigger('@/usr/local')).toEqual({
|
||||
kind: '@',
|
||||
query: '/usr/local',
|
||||
tokenLength: 11,
|
||||
value: '/usr/local'
|
||||
})
|
||||
expect(detectTrigger('@apps/desktop/src')).toEqual({
|
||||
kind: '@',
|
||||
query: 'apps/desktop/src',
|
||||
tokenLength: 17
|
||||
tokenLength: 17,
|
||||
value: 'apps/desktop/src'
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -70,20 +84,48 @@ describe('detectTrigger', () => {
|
|||
// assistant-ui's Lexical DirectivePlugin gets the same semantics from node
|
||||
// boundaries: typing a trigger right after a chip (no space) still opens
|
||||
// the popover, and a chip inside a token ends it.
|
||||
expect(detectTrigger('\uFFFC@Desk')).toEqual({ kind: '@', query: 'Desk', tokenLength: 5 })
|
||||
expect(detectTrigger('\uFFFC@Desk')).toEqual({ kind: '@', query: 'Desk', tokenLength: 5, value: 'Desk' })
|
||||
// Not position 0, so it's an inline reference — not a command invocation.
|
||||
expect(detectTrigger('\uFFFC/cle')).toEqual({ inline: true, kind: '/', query: 'cle', tokenLength: 4 })
|
||||
expect(detectTrigger('\uFFFC/cle')).toEqual({
|
||||
inline: true,
|
||||
kind: '/',
|
||||
query: 'cle',
|
||||
tokenLength: 4,
|
||||
value: 'cle'
|
||||
})
|
||||
// The placeholder itself never leaks into a query.
|
||||
expect(detectTrigger('@a\uFFFCb')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the at-mention live for a typed ref kind with a path', () => {
|
||||
it('splits a typed ref kind off as the browse scope', () => {
|
||||
// `@folder:apps/` is ONE token with TWO parts. The kind is the mode the
|
||||
// user is browsing in, so it's held as `scope` rather than left in `value`
|
||||
// for every consumer to re-parse (or, worse, to preserve by hand).
|
||||
expect(detectTrigger('@file:src/main.tsx')).toEqual({
|
||||
kind: '@',
|
||||
query: 'file:src/main.tsx',
|
||||
tokenLength: 18
|
||||
scope: 'file',
|
||||
tokenLength: 18,
|
||||
value: 'src/main.tsx'
|
||||
})
|
||||
expect(detectTrigger('@folder:apps/')).toEqual({ kind: '@', query: 'folder:apps/', tokenLength: 13 })
|
||||
expect(detectTrigger('@folder:apps/')).toEqual({
|
||||
kind: '@',
|
||||
query: 'folder:apps/',
|
||||
scope: 'folder',
|
||||
tokenLength: 13,
|
||||
value: 'apps/'
|
||||
})
|
||||
// A scope with nothing typed after it is the empty-browse state the
|
||||
// popover renders a header for.
|
||||
expect(detectTrigger('@url:')).toEqual({ kind: '@', query: 'url:', scope: 'url', tokenLength: 5, value: '' })
|
||||
})
|
||||
|
||||
it('only treats a KNOWN kind as a scope', () => {
|
||||
// `@teknium1:` is a handle with a colon, not a directive — inventing a
|
||||
// scope for it would make Backspace eat the whole word.
|
||||
expect(detectTrigger('@teknium1:')?.scope).toBeUndefined()
|
||||
expect(detectTrigger('@teknium1:')?.value).toBe('teknium1:')
|
||||
expect(detectTrigger('@localhost:8080')?.scope).toBeUndefined()
|
||||
})
|
||||
|
||||
it('still ends the at-mention token at whitespace', () => {
|
||||
|
|
@ -92,15 +134,28 @@ describe('detectTrigger', () => {
|
|||
expect(detectTrigger('look at @apps/desktop')).toEqual({
|
||||
kind: '@',
|
||||
query: 'apps/desktop',
|
||||
tokenLength: 13
|
||||
tokenLength: 13,
|
||||
value: 'apps/desktop'
|
||||
})
|
||||
})
|
||||
|
||||
it('treats a mid-message slash as an inline reference', () => {
|
||||
// Skills have to be reachable anywhere in a prompt, not just at position 0.
|
||||
expect(detectTrigger('hello /')).toEqual({ kind: '/', inline: true, query: '', tokenLength: 1 })
|
||||
expect(detectTrigger('hello /clean')).toEqual({ kind: '/', inline: true, query: 'clean', tokenLength: 6 })
|
||||
expect(detectTrigger('text\n/skill')).toEqual({ kind: '/', inline: true, query: 'skill', tokenLength: 6 })
|
||||
expect(detectTrigger('hello /')).toEqual({ kind: '/', inline: true, query: '', tokenLength: 1, value: '' })
|
||||
expect(detectTrigger('hello /clean')).toEqual({
|
||||
kind: '/',
|
||||
inline: true,
|
||||
query: 'clean',
|
||||
tokenLength: 6,
|
||||
value: 'clean'
|
||||
})
|
||||
expect(detectTrigger('text\n/skill')).toEqual({
|
||||
kind: '/',
|
||||
inline: true,
|
||||
query: 'skill',
|
||||
tokenLength: 6,
|
||||
value: 'skill'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not carry arg completion into an inline slash reference', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue