{"repo":"NousResearch/hermes-agent","public_id":"41323","display_label":"Desktop slash commands with newlines in arguments parsing","summary":"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.","subsystem":"apps/desktop","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":57226,"merged_at":"2026-07-02T16:42:01+00:00"},"resolution":{"state":"resolved","by":"pr-57226"},"freshness":{"state":"verified","last_verified_at":"2026-07-02T16:42:01+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":41323,"pr_number":57226,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-02T16:42:01+00:00"},{"issue_number":55510,"pr_number":57226,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-02T16:42:01+00:00"}],"members":[{"kind":"issue","number":41323,"title":"bug(desktop): /goal with newline returns empty slash command","url":null,"state":"closed"},{"kind":"pr","number":41384,"title":"fix(desktop): parse multi-line slash commands in Desktop app","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#41522","number":41522},{"kind":"pr","value":"#41323","number":41323}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":41522,"title":"fix(#41323): parse slash commands with newlines in arguments","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#41323","number":41323},{"kind":"pr","value":"#57226","number":57226},{"kind":"pr","value":"#55541","number":55541}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":57226,"title":"fix(desktop): parse multiline slash commands so long-context skill/goal payloads stop vanishing (fixes #41323, supersedes #55541)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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"],"fix_hunks":[{"file":"apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx","hunks":["@@ -4,7 +4,7 @@ import { useEffect, useRef } from 'react'\n import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\n \n import { textPart } from '@/lib/chat-messages'\n-import { $composerAttachments, type ComposerAttachment } from '@/store/composer'\n+import { $composerAttachments, $composerDraft, type ComposerAttachment, setComposerDraft } from '@/store/composer'\n import { $busy, $connection, $messages, $sessions, setSessions } from '@/store/session'\n import type { SessionInfo } from '@/types/hermes'\n ","@@ -272,6 +272,70 @@ describe('usePromptActions slash.exec dispatch payloads', () => {\n     expect(renderedText).toContain('⊙ Goal set. Starting now.')\n     expect(renderedText).not.toContain('/goal: no output')\n   })\n+\n+  it('dispatches a slash command with a multiline arg instead of \"empty slash command\" (#41323, #55510)', async () => {\n+    const calls: { method: string; params?: Record<string, unknown> }[] = []\n+    const states: Record<string, unknown>[] = []\n+\n+    const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {\n+      calls.push({ method, params })\n+\n+      if (method === 'slash.exec') {\n+        return { type: 'send', message: 'Write a Python script\\nthat prints Hello World' } as never\n+      }\n+\n+      return {} as never\n+    })\n+\n+    let handle: HarnessHandle | null = null\n+    render(\n+      <Harness\n+        onReady={h => (handle = h)}\n+        onSeedState={s => states.push(s)}\n+        refreshSessions={async () => undefined}\n+        requestGateway={requestGateway}\n+      />\n+    )\n+\n+    await handle!.submitText('/goal Write a Python script\\nthat prints Hello World')\n+\n+    // The newline lives in the arg — the command still reaches the gateway\n+    // whole, exactly as the CLI and Telegram handle it.\n+    expect(calls.map(c => c.method)).toEqual(['slash.exec', 'prompt.submit'])\n+    expect(calls[0]?.params).toEqual({\n+      command: 'goal Write a Python script\\nthat prints Hello World',\n+      session_id: RUNTIME_SESSION_ID\n+    })\n+\n+    const renderedText = states\n+      .flatMap(state => {\n+        const messages = Array.isArray(state.messages)\n+          ? (state.messages as Array<{ parts?: Array<{ text?: string }> }>)\n+          : []\n+\n+        return messages.flatMap(message => (message.parts ?? []).map(part => part.text ?? ''))\n+      })\n+      .join('\\n')\n+\n+    expect(renderedText).not.toContain('empty slash command')\n+  })\n+\n+  it('restores a degenerate slash payload to the composer instead of losing it', async () => {\n+    setComposerDraft('')\n+\n+    const requestGateway = vi.fn(async () => ({}) as never)\n+\n+    let handle: HarnessHandle | null = null\n+    render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)\n+\n+    // `/ text` parses to an empty command name on every surface (CLI parity).\n+    // The composer draft was already cleared on submit and slash input never\n+    // enters the Up-arrow history ring, so the payload must be handed back.\n+    await handle!.submitText('/ pasted context that must not vanish')\n+\n+    expect($composerDraft.get()).toBe('/ pasted context that must not vanish')\n+    expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())\n+  })\n })\n \n describe('usePromptActions desktop slash pickers', () => {"]},{"file":"apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts","hunks":["@@ -561,6 +561,14 @@ export function useSlashCommand(deps: SlashCommandDeps) {\n         const { name, arg } = parseSlashCommand(command)\n \n         if (!name) {\n+          // The composer draft was already cleared on submit, and slash input\n+          // never lands in the Up-arrow history ring (it derives from sent user\n+          // messages) — so without this restore, any payload after a degenerate\n+          // slash (`/ text`, `/` + newline) is lost forever. Hand it back.\n+          if (command.replace(/^\\/+/, '').trim()) {\n+            setComposerDraft(command)\n+          }\n+\n           const sessionId = await ensureSessionId(sessionHint)\n \n           if (sessionId) {"]},{"file":"apps/desktop/src/lib/chat-runtime.test.ts","hunks":["@@ -6,7 +6,8 @@ import {\n   attachmentDisplayText,\n   coerceThinkingText,\n   optimisticAttachmentRef,\n-  parseCommandDispatch\n+  parseCommandDispatch,\n+  parseSlashCommand\n } from './chat-runtime'\n \n const DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANS'","@@ -111,3 +112,41 @@ describe('parseCommandDispatch', () => {\n     expect(parseCommandDispatch({ type: 'prefill', notice: 'x' })).toBeNull()\n   })\n })\n+\n+describe('parseSlashCommand', () => {\n+  it('parses a single-line command', () => {\n+    expect(parseSlashCommand('/some-skill do something')).toEqual({\n+      arg: 'do something',\n+      name: 'some-skill'\n+    })\n+  })\n+\n+  it('keeps a multiline arg intact instead of failing the whole parse (#41323)', () => {\n+    expect(parseSlashCommand('/goal Write a Python script\\nthat prints Hello World')).toEqual({\n+      arg: 'Write a Python script\\nthat prints Hello World',\n+      name: 'goal'\n+    })\n+  })\n+\n+  it('parses a skill command with a long pasted multi-paragraph context (#55510)', () => {\n+    const context = 'summarize this:\\n\\nparagraph one\\nparagraph two\\n\\nparagraph three'\n+\n+    expect(parseSlashCommand(`/some-skill ${context}`)).toEqual({\n+      arg: context,\n+      name: 'some-skill'\n+    })\n+  })\n+\n+  it('takes the name across a newline boundary like the CLI and gateway (split on any whitespace)', () => {\n+    expect(parseSlashCommand('/goal\\npasted block')).toEqual({ arg: 'pasted block', name: 'goal' })\n+  })\n+\n+  it('keeps truly empty slash input empty', () => {\n+    expect(parseSlashCommand('/')).toEqual({ arg: '', name: '' })\n+    expect(parseSlashCommand('/   ')).toEqual({ arg: '', name: '' })\n+  })\n+\n+  it('does not treat text after horizontal whitespace as a command name (CLI parity)', () => {\n+    expect(parseSlashCommand('/ some words')).toEqual({ arg: '', name: '' })\n+  })\n+})"]},{"file":"apps/desktop/src/lib/chat-runtime.ts","hunks":["@@ -223,7 +223,12 @@ export function normalizePersonalityValue(value: string): string {\n }\n \n export function parseSlashCommand(command: string) {\n-  const match = command.replace(/^\\/+/, '').match(/^(\\S+)\\s*(.*)$/)\n+  // `[\\s\\S]*` (not `.*`): the arg may span newlines — `/goal <multi-line text>`\n+  // or a skill command with a long pasted context. The old `.*$` regex failed\n+  // the whole match on any newline, so every multiline slash command parsed as\n+  // an empty name and got swallowed (#41323, #55510). The backend and CLI both\n+  // split on any whitespace (`split(maxsplit=1)`), so this is the parity fix.\n+  const match = command.replace(/^\\/+/, '').match(/^(\\S+)([\\s\\S]*)$/)\n \n   return match ? { name: match[1], arg: match[2].trim() } : { name: '', arg: '' }\n }\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}