diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/btw-complete-event.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/btw-complete-event.test.tsx new file mode 100644 index 0000000000..36ab3936c3 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-message-stream/btw-complete-event.test.tsx @@ -0,0 +1,66 @@ +import { act, cleanup } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { RpcEvent } from '@/types/hermes' + +import { type MessageStreamHarness, renderMessageStream } from './test-harness' + +const SID = 'session-1' +const OTHER_SID = 'session-2' + +let stream: MessageStreamHarness + +function mountStream() { + stream = renderMessageStream(SID) +} + +function emit(type: RpcEvent['type'], payload: RpcEvent['payload'] = {}, sessionId = SID) { + act(() => stream.handleEvent({ payload, session_id: sessionId, type })) +} + +function lastMessage(id = SID) { + return stream.state(id).messages.at(-1) +} + +describe('btw.complete event', () => { + beforeEach(() => { + mountStream() + }) + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + }) + + // #99065: prompt.btw delivers the answer here. The slash-worker route + // printed it after the stdout capture window closed, so only the ack rendered. + it('appends the answer to the originating session as a system message', () => { + emit('btw.complete', { task_id: 'btw_ab12cd', question: 'which file was that error in?', text: 'src/main.ts' }) + + const message = lastMessage() + + expect(message?.role).toBe('system') + expect(message?.id).toBe('btw-complete-btw_ab12cd') + expect(stream.text()).toBe('[btw "which file was that error in?" (btw_ab12cd)]\nsrc/main.ts') + }) + + it('keeps another session untouched when the event targets this one', () => { + emit('btw.complete', { task_id: 'btw_1', text: 'for the other chat' }, OTHER_SID) + + expect(lastMessage()).toBeUndefined() + expect(stream.text(OTHER_SID)).toBe('[btw (btw_1)]\nfor the other chat') + }) + + it('omits the question and task-id header bits when the backend omits them', () => { + emit('btw.complete', { text: 'the answer' }) + + expect(stream.text()).toBe('[btw]\nthe answer') + }) + + it('drops an empty completion instead of appending a blank line', () => { + emit('btw.complete', { task_id: 'btw_1', question: 'q', text: ' ' }) + + expect(lastMessage()).toBeUndefined() + }) +}) +) \ No newline at end of file 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 0bc1f47c65..d2a2ad5334 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 @@ -1002,6 +1002,99 @@ describe('usePromptActions /compress', () => { }) }) +describe('usePromptActions /btw', () => { + afterEach(() => { + cleanup() + vi.restoreAllMocks() + }) + + // #99065: slash.exec only captured the ack; the answer prints after the + // worker's stdout window. prompt.btw + btw.complete is the TUI path. + it('routes through prompt.btw (not slash.exec) and renders the start notice', async () => { + const seeds: Record[] = [] + + const requestGateway = vi.fn(async (method: string) => { + if (method === 'prompt.btw') { + return { task_id: 'btw_ab12cd' } as never + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + await actRender( + (handle = h)} + onSeedState={s => seeds.push(s)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + /> + ) + + await handle!.submitText('/btw which file was that error in?') + + expect(requestGateway).toHaveBeenCalledWith('prompt.btw', { + session_id: RUNTIME_SESSION_ID, + text: 'which file was that error in?' + }) + expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything()) + expect(requestGateway).not.toHaveBeenCalledWith('command.dispatch', expect.anything()) + expect(renderedSeedTexts(seeds).some(text => text.includes('btw_ab12cd'))).toBe(true) + }) + + it('shows usage when no question is typed', async () => { + const seeds: Record[] = [] + + const requestGateway = vi.fn(async () => ({} as never)) + + let handle: HarnessHandle | null = null + await actRender( + (handle = h)} + onSeedState={s => seeds.push(s)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + /> + ) + + await handle!.submitText('/btw') + + expect(requestGateway).not.toHaveBeenCalled() + expect(renderedSeedTexts(seeds).some(text => text.includes('Usage: /btw'))).toBe(true) + }) + + it('falls back to the slash worker when an older gateway lacks prompt.btw', async () => { + const seeds: Record[] = [] + + const requestGateway = vi.fn(async (method: string) => { + if (method === 'prompt.btw') { + throw new Error('method not found: prompt.btw') + } + + if (method === 'slash.exec') { + return { output: 'Side question started by legacy gateway' } as never + } + + throw new Error(`unexpected method: ${method}`) + }) + + let handle: HarnessHandle | null = null + await actRender( + (handle = h)} + onSeedState={s => seeds.push(s)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + /> + ) + + await handle!.submitText('/btw anything') + + expect(requestGateway).toHaveBeenCalledWith('slash.exec', expect.objectContaining({ command: 'btw anything' })) + expect(renderedSeedTexts(seeds).some(text => text.includes('legacy gateway'))).toBe(true) + }) +}) + describe('usePromptActions exec fallback error reporting', () => { beforeEach(() => { setSessions(() => [sessionInfo()]) diff --git a/apps/desktop/src/lib/desktop-slash-commands.test.ts b/apps/desktop/src/lib/desktop-slash-commands.test.ts index cd39fcce9d..004cfa85f7 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.test.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.test.ts @@ -232,9 +232,9 @@ describe('desktop slash command curation', () => { }) it('still routes commands without dedicated RPCs through exec()', () => { + // /btw is an action (prompt.btw) — the slash-worker print never reached Desktop. const execNames = [ '/bg', - '/btw', '/debug', '/goal', '/personality', @@ -251,6 +251,13 @@ describe('desktop slash command curation', () => { } }) + it('routes /btw to the prompt.btw side-question action', () => { + expect(resolveDesktopCommand('/btw')?.surface).toEqual({ kind: 'action', action: 'btw' }) + expect(isDesktopSlashCommand('/btw')).toBe(true) + expect(isDesktopSlashSuggestion('/btw')).toBe(true) + expect(desktopSlashUnavailableMessage('/btw')).toBeNull() + }) + it('distinguishes free prose from finite slash option lists', () => { expect(desktopSlashCommandArgumentMode('/goal')).toBe('mixed') expect(desktopSlashCommandArgumentMode('/steer')).toBe('text')