diff --git a/cli/src/commands/__tests__/command-args.test.ts b/cli/src/commands/__tests__/command-args.test.ts index c755c953ae..47b8a1d691 100644 --- a/cli/src/commands/__tests__/command-args.test.ts +++ b/cli/src/commands/__tests__/command-args.test.ts @@ -164,7 +164,7 @@ describe('command factory pattern', () => { }) test('expected commands ignore args', () => { - const expectedNoArgs = ['login', 'logout', 'exit', 'usage', 'init'] + const expectedNoArgs = ['login', 'logout', 'exit', 'usage', 'init', 'compact'] for (const name of expectedNoArgs) { const cmd = COMMAND_REGISTRY.find((c) => c.name === name) expect(cmd, `Command ${name} should exist`).toBeDefined() diff --git a/cli/src/commands/__tests__/compact-command.test.ts b/cli/src/commands/__tests__/compact-command.test.ts new file mode 100644 index 0000000000..449147836a --- /dev/null +++ b/cli/src/commands/__tests__/compact-command.test.ts @@ -0,0 +1,182 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test' + +import { useChatStore } from '../../state/chat-store' +import { findCommand } from '../command-registry' +import { handleCompactCommand } from '../compact' +import { parseCommandInput } from '../router-utils' +import { getUserMessage } from '../../utils/message-history' + +import type { RouterParams } from '../command-registry' +import type { ChatMessage } from '../../types/chat' + +const createMockParams = ( + overrides: Partial = {}, +): RouterParams => + ({ + agentMode: 'DEFAULT', + inputRef: { current: { focus: mock(() => {}) } as never }, + inputValue: '/compact', + isChainInProgressRef: { current: false }, + isStreaming: false, + logoutMutation: {} as RouterParams['logoutMutation'], + streamMessageIdRef: { current: null }, + addToQueue: mock(() => {}), + hasQueuedMessages: () => false, + clearMessages: mock(() => {}), + saveToHistory: mock(() => {}), + scrollToLatest: mock(() => {}), + sendMessage: mock(async () => {}), + setCanProcessQueue: mock(() => {}), + setInputFocused: mock(() => {}), + setInputValue: mock(() => {}), + setIsAuthenticated: mock(() => {}), + setMessages: mock(() => {}), + setUser: mock(() => {}), + ...overrides, + }) as RouterParams + +describe('handleCompactCommand', () => { + beforeEach(() => { + useChatStore.setState({ + messages: [], + pendingAttachments: [], + }) + }) + + test('bails out early with system message when conversation is empty', async () => { + const params = createMockParams({ inputValue: '/compact' }) + + await handleCompactCommand(params) + + expect(params.saveToHistory).toHaveBeenCalledWith('/compact') + expect(params.setInputValue).toHaveBeenCalledWith({ + text: '', + cursorPosition: 0, + lastEditDueToNav: false, + }) + expect(params.setMessages).toHaveBeenCalledTimes(1) + + // Inspect the system message added + const updater = (params.setMessages as ReturnType).mock + .calls[0][0] as (prev: ChatMessage[]) => ChatMessage[] + const resultingMessages = updater([]) + expect(resultingMessages.length).toBe(1) + expect(resultingMessages[0].content).toContain('Nothing to compact') + expect(resultingMessages[0].id).toStartWith('sys-') + + // Must NOT call sendMessage or addToQueue + expect(params.sendMessage).not.toHaveBeenCalled() + expect(params.addToQueue).not.toHaveBeenCalled() + }) + + test('dispatches /compact to sendMessage when idle and messages exist', async () => { + useChatStore.setState({ + messages: [getUserMessage('Hello, world!')], + }) + + const params = createMockParams({ + inputValue: '/compact', + agentMode: 'DEFAULT', + }) + + await handleCompactCommand(params) + + expect(params.saveToHistory).toHaveBeenCalledWith('/compact') + expect(params.setInputValue).toHaveBeenCalledWith({ + text: '', + cursorPosition: 0, + lastEditDueToNav: false, + }) + expect(params.addToQueue).not.toHaveBeenCalled() + expect(params.sendMessage).toHaveBeenCalledWith({ + content: '/compact', + agentMode: 'DEFAULT', + }) + }) + + test('queues /compact when streaming is in progress', async () => { + useChatStore.setState({ + messages: [getUserMessage('Running code')], + }) + + const params = createMockParams({ + inputValue: '/compact', + isStreaming: true, + }) + + await handleCompactCommand(params) + + expect(params.saveToHistory).toHaveBeenCalledWith('/compact') + expect(params.addToQueue).toHaveBeenCalledWith('/compact', []) + expect(params.setInputFocused).toHaveBeenCalledWith(true) + expect(params.sendMessage).not.toHaveBeenCalled() + }) + + test('queues /compact when chain is in progress', async () => { + useChatStore.setState({ + messages: [getUserMessage('Running chain')], + }) + + const params = createMockParams({ + inputValue: '/compact', + isChainInProgressRef: { current: true }, + }) + + await handleCompactCommand(params) + + expect(params.addToQueue).toHaveBeenCalledWith('/compact', []) + expect(params.sendMessage).not.toHaveBeenCalled() + }) + + test('queues /compact when streamMessageIdRef is active', async () => { + useChatStore.setState({ + messages: [getUserMessage('Active stream')], + }) + + const params = createMockParams({ + inputValue: '/compact', + streamMessageIdRef: { current: 'ai-msg-123' }, + }) + + await handleCompactCommand(params) + + expect(params.addToQueue).toHaveBeenCalledWith('/compact', []) + expect(params.sendMessage).not.toHaveBeenCalled() + }) +}) + +describe('compact command registry and aliases', () => { + test('findCommand resolves compact by primary name', () => { + const cmd = findCommand('compact') + expect(cmd).toBeDefined() + expect(cmd?.name).toBe('compact') + expect(cmd?.aliases).toContain('summarize') + expect(cmd?.acceptsArgs).toBe(false) + }) + + test('findCommand resolves compact by summarize alias', () => { + const cmd = findCommand('summarize') + expect(cmd).toBeDefined() + expect(cmd?.name).toBe('compact') + }) + + test('parseCommandInput supports /compact and slashless compact', () => { + expect(parseCommandInput('/compact')).toEqual({ + command: 'compact', + args: '', + implicitCommand: false, + }) + + expect(parseCommandInput('compact')).toEqual({ + command: 'compact', + args: '', + implicitCommand: true, + }) + + expect(parseCommandInput('/summarize')).toEqual({ + command: 'summarize', + args: '', + implicitCommand: false, + }) + }) +}) diff --git a/cli/src/commands/command-registry.ts b/cli/src/commands/command-registry.ts index 9c381796fc..2e5122793b 100644 --- a/cli/src/commands/command-registry.ts +++ b/cli/src/commands/command-registry.ts @@ -9,6 +9,7 @@ import { handleProposalReport, handleProposalsOff, } from './ads' +import { handleCompactCommand } from './compact' import { handleCopyConversationCommand } from './copy-conversation' import { handleExportConversationCommand } from './export-conversation' import { handleHelpCommand } from './help' @@ -313,6 +314,13 @@ const ALL_COMMANDS: CommandDefinition[] = [ await handleExportConversationCommand(params, args) }, }), + defineCommand({ + name: 'compact', + aliases: ['summarize'], + handler: async (params) => { + await handleCompactCommand(params) + }, + }), defineCommandWithArgs({ name: 'feedback', aliases: ['bug', 'report'], diff --git a/cli/src/commands/compact.ts b/cli/src/commands/compact.ts new file mode 100644 index 0000000000..d3a95059e7 --- /dev/null +++ b/cli/src/commands/compact.ts @@ -0,0 +1,50 @@ +import { useChatStore } from '../state/chat-store' +import { getSystemMessage } from '../utils/message-history' +import { capturePendingAttachments } from '../utils/pending-attachments' + +import type { RouterParams } from './command-registry' + +/** + * Handle the `/compact` (and `/summarize`) command. + * + * Bails out early with a system message when the conversation is empty, + * queues the command if a stream or chain is actively running, + * or dispatches `/compact` to the agent runtime to summarize and compact history. + */ +export async function handleCompactCommand( + params: RouterParams, +): Promise { + const trimmed = params.inputValue.trim() + params.saveToHistory(trimmed) + params.setInputValue({ text: '', cursorPosition: 0, lastEditDueToNav: false }) + + const messages = useChatStore.getState().messages + if (messages.length === 0) { + params.setMessages((prev) => [ + ...prev, + getSystemMessage('Nothing to compact — the conversation is empty.'), + ]) + return + } + + // Check streaming/queue state + if ( + params.isStreaming || + params.streamMessageIdRef.current || + params.isChainInProgressRef.current + ) { + const pendingAttachments = capturePendingAttachments() + params.addToQueue('/compact', pendingAttachments) + params.setInputFocused(true) + params.inputRef.current?.focus() + return + } + + params.sendMessage({ + content: '/compact', + agentMode: params.agentMode, + }) + setTimeout(() => { + params.scrollToLatest() + }, 0) +} diff --git a/cli/src/data/slash-commands.ts b/cli/src/data/slash-commands.ts index c9a1321c11..37d1eeca7d 100644 --- a/cli/src/data/slash-commands.ts +++ b/cli/src/data/slash-commands.ts @@ -146,6 +146,13 @@ const ALL_SLASH_COMMANDS: SlashCommand[] = [ description: 'Write the full conversation to a file (.md, or .json for raw messages)', aliases: ['export-chat'], }, + { + id: 'compact', + label: 'compact', + description: 'Compact conversation history to reclaim context space', + aliases: ['summarize'], + implicitCommand: true, + }, { id: 'agent:gpt-5', label: 'agent:gpt-5',