NousResearch/hermes-agent

Desktop skill slash command parsing and rendering bugs

Cluster 55510 · 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
3items grouped
Known?
Yes. 3 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 issues and fixes concern bugs in the desktop client where skill slash commands render incorrectly, lose payload content across multiple lines, or produce degraded chip states. The work targets parsing and display reliability for skill and goal slash command inputs.

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

1 PR closed without merging

From the close comments: other 1

  • #55541 fix(desktop): recover degraded skill slash chips · other → #55510
Closed issues · 1
  • Closed#55510bug(desktop): skill slash command can render empty slash command despite non-empty input
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.67: 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
1 PR closed without merging
Fix link checked
2 Jul 2026

For your agent

The same facts, typed.