diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index d0290aa27fc..c824e6e3cb6 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/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 }[] = [] + const states: Record[] = [] + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + 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( + (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( (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', () => { diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts index 4d887f4ef64..3c918c7ed40 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts +++ b/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) { diff --git a/apps/desktop/src/lib/chat-runtime.test.ts b/apps/desktop/src/lib/chat-runtime.test.ts index 9d30dfb1c38..b3d4e638537 100644 --- a/apps/desktop/src/lib/chat-runtime.test.ts +++ b/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: '' }) + }) +}) diff --git a/apps/desktop/src/lib/chat-runtime.ts b/apps/desktop/src/lib/chat-runtime.ts index 9cd0c923d1d..2ae7dd262e1 100644 --- a/apps/desktop/src/lib/chat-runtime.ts +++ b/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 ` + // 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: '' } }