Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/src/commands/__tests__/command-args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
182 changes: 182 additions & 0 deletions cli/src/commands/__tests__/compact-command.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {},
): 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<typeof mock>).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,
})
})
})
8 changes: 8 additions & 0 deletions cli/src/commands/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'],
Expand Down
50 changes: 50 additions & 0 deletions cli/src/commands/compact.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
}
7 changes: 7 additions & 0 deletions cli/src/data/slash-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading