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:
brooklyn! 2026-07-02 11:42:00 -05:00 committed by GitHub
commit 63354edfd7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 119 additions and 3 deletions

View file

@ -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', () => {

View file

@ -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) {

View file

@ -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: '' })
})
})

View file

@ -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: '' }
}