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 a06ee1294c0..66e096dff17 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 @@ -278,6 +278,83 @@ describe('usePromptActions /title', () => { }) }) +describe('usePromptActions /compress', () => { + beforeEach(() => { + setSessions(() => [sessionInfo()]) + }) + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + }) + + it('calls session.compress (not slash.exec) and replaces the transcript from the response', async () => { + // Seed a long-looking transcript so we can prove /compress swaps it out + // for the post-compress history the RPC returns — not just prints a line. + $messages.set([ + { id: 'm1', parts: [textPart('old message one')], role: 'user', timestamp: 0 }, + { id: 'm2', parts: [textPart('old message two')], role: 'assistant', timestamp: 0 }, + { id: 'm3', parts: [textPart('old message three')], role: 'user', timestamp: 0 }, + { id: 'm4', parts: [textPart('old message four')], role: 'assistant', timestamp: 0 } + ]) + + const requestGateway = vi.fn(async (method: string) => { + if (method === 'session.compress') { + return { + removed: 2, + status: 'compressed', + summary: { + headline: '✓ compressed 4 → 2 messages', + token_line: '~8.2k → ~2.1k tok' + }, + messages: [ + { role: 'user', content: 'summarized context' }, + { role: 'assistant', content: 'sure, here is the summary' } + ] + } as never + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + await actRender( (handle = h)} refreshSessions={vi.fn(async () => undefined)} requestGateway={requestGateway} />) + + await handle!.submitText('/compress') + + // Routes through the dedicated session.compress RPC with the runtime id — + // NOT slash.exec, which only returns a summary string and leaves stale + // bubbles on screen (the bug this fixes). + expect(requestGateway).toHaveBeenCalledWith('session.compress', expect.objectContaining({ session_id: RUNTIME_SESSION_ID })) + expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything()) + + // The transcript was replaced with the post-compress history, so the old + // messages are gone and only the summarized pair remains. + const messages = $messages.get() + expect(messages).toHaveLength(2) + expect(messages.every(m => !m.parts.some(p => 'text' in p && p.text.includes('old message')))).toBe(true) + }) + + it('forwards a focus topic arg as focus_topic to session.compress', async () => { + $messages.set([{ id: 'm1', parts: [textPart('ctx')], role: 'user', timestamp: 0 }]) + + const requestGateway = vi.fn(async (method: string) => { + if (method === 'session.compress') { + return { removed: 0, status: 'aborted', summary: { headline: 'nothing to compress', noop: true } } as never + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + await actRender( (handle = h)} refreshSessions={vi.fn(async () => undefined)} requestGateway={requestGateway} />) + + await handle!.submitText('/compress the deployment bug') + + expect(requestGateway).toHaveBeenCalledWith('session.compress', expect.objectContaining({ focus_topic: 'the deployment bug' })) + }) +}) + describe('usePromptActions slash.exec dispatch payloads', () => { afterEach(() => { cleanup() diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index 1fde9070d4a..18c30e18d42 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -478,6 +478,7 @@ export function usePromptActions({ refreshSessions, requestGateway, resumeStoredSession, + setMessages, startFreshSessionDraft, submitPromptText }) 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 d80c65f6bbf..23e94b0607f 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 @@ -2,7 +2,7 @@ import { type MutableRefObject, useCallback } from 'react' import { getProfiles } from '@/hermes' import type { Translations } from '@/i18n' -import { type ChatMessage } from '@/lib/chat-messages' +import { type ChatMessage, toChatMessages } from '@/lib/chat-messages' import { parseCommandDispatch, parseSlashCommand, sessionTitle } from '@/lib/chat-runtime' import { type CommandsCatalogLike, @@ -29,7 +29,7 @@ import { setYoloActive } from '@/store/session' -import type { BrowserManageResponse, SessionTitleResponse, SlashExecResponse } from '../../../types' +import type { BrowserManageResponse, SessionCompressResponse, SessionTitleResponse, SlashExecResponse } from '../../../types' import { type GatewayRequest, @@ -64,6 +64,7 @@ interface SlashCommandDeps { refreshSessions: () => Promise requestGateway: GatewayRequest resumeStoredSession: (storedSessionId: string) => Promise | void + setMessages: (updater: ChatMessage[] | ((current: ChatMessage[]) => ChatMessage[])) => void startFreshSessionDraft: () => void submitPromptText: (rawText: string, options?: SubmitTextOptions) => Promise } @@ -83,6 +84,7 @@ export function useSlashCommand(deps: SlashCommandDeps) { refreshSessions, requestGateway, resumeStoredSession, + setMessages, startFreshSessionDraft, submitPromptText } = deps @@ -515,6 +517,62 @@ export function useSlashCommand(deps: SlashCommandDeps) { } catch (err) { renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`) } + }, + + // /compress (alias /compact) summarizes older turns into a compact + // preamble. Unlike plain slash.exec commands, it mutates the live session + // history server-side — so the desktop must replace its transcript from + // the response's `messages` (the same field session.resume returns) or the + // summarized bubbles stay on screen forever, making /compress look like + // a no-op. Mirrors the TUI's session.compress path: call the dedicated + // RPC, swap the transcript, then show the feedback headline. + compress: async ctx => { + const resolved = await withSlashOutput(ctx) + + if (!resolved) { + return + } + + const { render: renderSlashOutput, sessionId } = resolved + + if (busyRef.current) { + renderSlashOutput('session busy — /interrupt the current turn before /compress') + + return + } + + const focusTopic = ctx.arg.trim() + + try { + const result = await requestGateway('session.compress', { + session_id: sessionId, + ...(focusTopic ? { focus_topic: focusTopic } : {}) + }) + + // Replace the transcript with the post-compress history so the + // summarized bubbles actually disappear. `messages` is the same + // shape session.resume returns (_history_to_messages), so + // toChatMessages handles it directly. + if (Array.isArray(result?.messages)) { + setMessages(toChatMessages(result.messages)) + } + + const summary = result?.summary + + const lines = [summary?.headline, summary?.token_line, summary?.note].filter( + (line): line is string => Boolean(line) + ) + + if (lines.length > 0) { + renderSlashOutput(lines.join('\n')) + } else if ((result?.removed ?? 0) > 0) { + renderSlashOutput(`compressed ${result?.removed} messages`) + } else { + renderSlashOutput('nothing to compress') + } + } catch (err) { + renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`) + } } } @@ -628,6 +686,7 @@ export function useSlashCommand(deps: SlashCommandDeps) { refreshSessions, requestGateway, resumeStoredSession, + setMessages, startFreshSessionDraft, submitPromptText ] diff --git a/apps/desktop/src/app/types.ts b/apps/desktop/src/app/types.ts index 3f8da414433..17de9b32084 100644 --- a/apps/desktop/src/app/types.ts +++ b/apps/desktop/src/app/types.ts @@ -1,7 +1,7 @@ import type * as React from 'react' import type { ChatMessage } from '@/lib/chat-messages' -import type { UsageStats } from '@/types/hermes' +import type { SessionMessage, UsageStats } from '@/types/hermes' export interface ContextSuggestion { text: string @@ -68,6 +68,27 @@ export interface SessionTitleResponse { session_key?: string } +/** Response from the `session.compress` RPC. `messages` is the post-compress + * history (same shape `session.resume` returns), so the desktop can replace + * its transcript from it rather than leaving stale bubbles on screen. `summary` + * carries the human-readable "compressed N → M messages" feedback line. */ +export interface SessionCompressResponse { + after_messages?: number + after_tokens?: number + before_messages?: number + before_tokens?: number + messages?: SessionMessage[] + removed?: number + status?: string + summary?: { + aborted?: boolean + headline?: string + noop?: boolean + note?: null | string + token_line?: string + } +} + export interface HandoffRequestResponse { queued?: boolean session_key?: string diff --git a/apps/desktop/src/lib/desktop-slash-commands.test.ts b/apps/desktop/src/lib/desktop-slash-commands.test.ts index 0a108e77ba8..51e23918588 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.test.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.test.ts @@ -73,6 +73,22 @@ describe('desktop slash command curation', () => { expect(resolveDesktopCommand('/browser')?.args).toBe(true) }) + it('routes /compress through a desktop action that calls session.compress', () => { + // /compress mutates the live session history server-side, so it must call + // the session.compress RPC directly (like the TUI) and replace the + // transcript from the response — not go through slash.exec, which only + // returns a summary string and leaves stale bubbles on screen. + expect(resolveDesktopCommand('/compress')?.surface).toEqual({ kind: 'action', action: 'compress' }) + expect(resolveDesktopCommand('/compress')?.args).toBe(true) + expect(isDesktopSlashCommand('/compress')).toBe(true) + expect(isDesktopSlashSuggestion('/compress')).toBe(true) + expect(desktopSlashUnavailableMessage('/compress')).toBeNull() + // /compact is an alias — executes but stays out of the popover. + expect(resolveDesktopCommand('/compact')?.surface).toEqual({ kind: 'action', action: 'compress' }) + expect(isDesktopSlashCommand('/compact')).toBe(true) + expect(isDesktopSlashSuggestion('/compact')).toBe(false) + }) + it('routes /journey (and aliases) to the memory graph overlay action', () => { expect(resolveDesktopCommand('/journey')?.surface).toEqual({ kind: 'action', action: 'journey' }) expect(resolveDesktopCommand('/memory-graph')?.surface).toEqual({ kind: 'action', action: 'journey' }) diff --git a/apps/desktop/src/lib/desktop-slash-commands.ts b/apps/desktop/src/lib/desktop-slash-commands.ts index 20d5416f8db..43112291333 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.ts @@ -31,6 +31,7 @@ export interface DesktopThemeCommandOption { export type DesktopActionId = | 'branch' | 'browser' + | 'compress' | 'handoff' | 'hatch' | 'help' @@ -148,7 +149,13 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [ surface: exec() }, { name: '/background', description: 'Run a prompt in the background', aliases: ['/bg', '/btw'], surface: exec() }, - { name: '/compress', description: 'Compress this conversation context', surface: exec() }, + { + name: '/compress', + description: 'Compress this conversation context', + aliases: ['/compact'], + surface: action('compress'), + args: true + }, { name: '/debug', description: 'Create a debug report', surface: exec() }, { name: '/goal', description: 'Manage the standing goal for this session', surface: exec() }, { name: '/personality', description: 'Switch personality for this session', surface: exec(), args: true },