NousResearch/hermes-agent

Desktop slash commands with newlines in arguments parsing

Cluster 41323 · apps/desktop. Data as of

Nothing open on GitHub. Fix merged in #57226 on 2 Jul 2026. Check it covers your case.

The short answer

0open issues
0open PRs
4items grouped
Known?
Yes. 4 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #57226. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #57226. That is separate from the fix event.
Still open
Nothing open on GitHub.

These members all concern how the Desktop app parses slash commands whose arguments span multiple lines, including a report that /goal with a newline yields an empty command. Several are fix-oriented changes referencing the same issue (#41323); their outcome is not established here.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #57226 fix(desktop): parse multiline slash commands so long-context skill/goal payloads stop vanishing (fixes #41323, supersedes #55541)

    merged to main

    files: apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx, apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts, apps/desktop/src/lib/chat-runtime.test.ts, apps/desktop/src/lib/chat-runtime.ts

    Show the diff
    • apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx
      @@ -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', () => {
    • apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts
      @@ -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) {
    • apps/desktop/src/lib/chat-runtime.test.ts
      @@ -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: '' })
      +  })
      +})
    • apps/desktop/src/lib/chat-runtime.ts
      @@ -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: '' }
       }

2 PRs closed without merging

From the close comments: done elsewhere 1 · other 1

  • #41384 fix(desktop): parse multi-line slash commands in Desktop app · other → #41522, #41323
  • #41522 fix(#41323): parse slash commands with newlines in arguments · done elsewhere → #41323, #57226, #55541
Closed issues · 1
  • Closed#41323bug(desktop): /goal with newline returns empty slash command
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.50: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
apps/desktop
Merged PRs
#57226
Closed issues
1
Attempts
2 PRs closed without merging
Fix link checked
2 Jul 2026

For your agent

The same facts, typed.