mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
Merge pull request #57226 from NousResearch/bb/desktop-multiline-slash
fix(desktop): parse multiline slash commands so long-context skill/goal payloads stop vanishing (fixes #41323, supersedes #55541)
This commit is contained in:
commit
63354edfd7
4 changed files with 119 additions and 3 deletions
|
|
@ -4,7 +4,7 @@ import { useEffect, useRef } from 'react'
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { textPart } from '@/lib/chat-messages'
|
||||
import { $composerAttachments, type ComposerAttachment } from '@/store/composer'
|
||||
import { $composerAttachments, $composerDraft, type ComposerAttachment, setComposerDraft } from '@/store/composer'
|
||||
import { $busy, $connection, $messages, $sessions, setSessions } from '@/store/session'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
|
|
@ -272,6 +272,70 @@ describe('usePromptActions slash.exec dispatch payloads', () => {
|
|||
expect(renderedText).toContain('⊙ Goal set. Starting now.')
|
||||
expect(renderedText).not.toContain('/goal: no output')
|
||||
})
|
||||
|
||||
it('dispatches a slash command with a multiline arg instead of "empty slash command" (#41323, #55510)', async () => {
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
const states: Record<string, unknown>[] = []
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
calls.push({ method, params })
|
||||
|
||||
if (method === 'slash.exec') {
|
||||
return { type: 'send', message: 'Write a Python script\nthat prints Hello World' } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
onSeedState={s => states.push(s)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
|
||||
await handle!.submitText('/goal Write a Python script\nthat prints Hello World')
|
||||
|
||||
// The newline lives in the arg — the command still reaches the gateway
|
||||
// whole, exactly as the CLI and Telegram handle it.
|
||||
expect(calls.map(c => c.method)).toEqual(['slash.exec', 'prompt.submit'])
|
||||
expect(calls[0]?.params).toEqual({
|
||||
command: 'goal Write a Python script\nthat prints Hello World',
|
||||
session_id: RUNTIME_SESSION_ID
|
||||
})
|
||||
|
||||
const renderedText = states
|
||||
.flatMap(state => {
|
||||
const messages = Array.isArray(state.messages)
|
||||
? (state.messages as Array<{ parts?: Array<{ text?: string }> }>)
|
||||
: []
|
||||
|
||||
return messages.flatMap(message => (message.parts ?? []).map(part => part.text ?? ''))
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
expect(renderedText).not.toContain('empty slash command')
|
||||
})
|
||||
|
||||
it('restores a degenerate slash payload to the composer instead of losing it', async () => {
|
||||
setComposerDraft('')
|
||||
|
||||
const requestGateway = vi.fn(async () => ({}) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
||||
|
||||
// `/ text` parses to an empty command name on every surface (CLI parity).
|
||||
// The composer draft was already cleared on submit and slash input never
|
||||
// enters the Up-arrow history ring, so the payload must be handed back.
|
||||
await handle!.submitText('/ pasted context that must not vanish')
|
||||
|
||||
expect($composerDraft.get()).toBe('/ pasted context that must not vanish')
|
||||
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePromptActions desktop slash pickers', () => {
|
||||
|
|
|
|||
|
|
@ -561,6 +561,14 @@ export function useSlashCommand(deps: SlashCommandDeps) {
|
|||
const { name, arg } = parseSlashCommand(command)
|
||||
|
||||
if (!name) {
|
||||
// The composer draft was already cleared on submit, and slash input
|
||||
// never lands in the Up-arrow history ring (it derives from sent user
|
||||
// messages) — so without this restore, any payload after a degenerate
|
||||
// slash (`/ text`, `/` + newline) is lost forever. Hand it back.
|
||||
if (command.replace(/^\/+/, '').trim()) {
|
||||
setComposerDraft(command)
|
||||
}
|
||||
|
||||
const sessionId = await ensureSessionId(sessionHint)
|
||||
|
||||
if (sessionId) {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ import {
|
|||
attachmentDisplayText,
|
||||
coerceThinkingText,
|
||||
optimisticAttachmentRef,
|
||||
parseCommandDispatch
|
||||
parseCommandDispatch,
|
||||
parseSlashCommand
|
||||
} from './chat-runtime'
|
||||
|
||||
const DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANS'
|
||||
|
|
@ -111,3 +112,41 @@ describe('parseCommandDispatch', () => {
|
|||
expect(parseCommandDispatch({ type: 'prefill', notice: 'x' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseSlashCommand', () => {
|
||||
it('parses a single-line command', () => {
|
||||
expect(parseSlashCommand('/some-skill do something')).toEqual({
|
||||
arg: 'do something',
|
||||
name: 'some-skill'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a multiline arg intact instead of failing the whole parse (#41323)', () => {
|
||||
expect(parseSlashCommand('/goal Write a Python script\nthat prints Hello World')).toEqual({
|
||||
arg: 'Write a Python script\nthat prints Hello World',
|
||||
name: 'goal'
|
||||
})
|
||||
})
|
||||
|
||||
it('parses a skill command with a long pasted multi-paragraph context (#55510)', () => {
|
||||
const context = 'summarize this:\n\nparagraph one\nparagraph two\n\nparagraph three'
|
||||
|
||||
expect(parseSlashCommand(`/some-skill ${context}`)).toEqual({
|
||||
arg: context,
|
||||
name: 'some-skill'
|
||||
})
|
||||
})
|
||||
|
||||
it('takes the name across a newline boundary like the CLI and gateway (split on any whitespace)', () => {
|
||||
expect(parseSlashCommand('/goal\npasted block')).toEqual({ arg: 'pasted block', name: 'goal' })
|
||||
})
|
||||
|
||||
it('keeps truly empty slash input empty', () => {
|
||||
expect(parseSlashCommand('/')).toEqual({ arg: '', name: '' })
|
||||
expect(parseSlashCommand('/ ')).toEqual({ arg: '', name: '' })
|
||||
})
|
||||
|
||||
it('does not treat text after horizontal whitespace as a command name (CLI parity)', () => {
|
||||
expect(parseSlashCommand('/ some words')).toEqual({ arg: '', name: '' })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -223,7 +223,12 @@ export function normalizePersonalityValue(value: string): string {
|
|||
}
|
||||
|
||||
export function parseSlashCommand(command: string) {
|
||||
const match = command.replace(/^\/+/, '').match(/^(\S+)\s*(.*)$/)
|
||||
// `[\s\S]*` (not `.*`): the arg may span newlines — `/goal <multi-line text>`
|
||||
// or a skill command with a long pasted context. The old `.*$` regex failed
|
||||
// the whole match on any newline, so every multiline slash command parsed as
|
||||
// an empty name and got swallowed (#41323, #55510). The backend and CLI both
|
||||
// split on any whitespace (`split(maxsplit=1)`), so this is the parity fix.
|
||||
const match = command.replace(/^\/+/, '').match(/^(\S+)([\s\S]*)$/)
|
||||
|
||||
return match ? { name: match[1], arg: match[2].trim() } : { name: '', arg: '' }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue