Skip to content
Merged
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
28 changes: 28 additions & 0 deletions docs/features/manual-compaction-command/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Manual Compaction Command Plan

## Implementation

- Add a typed `sessions.compact` route and renderer client method that returns whether a new summary
was written plus the current compaction state.
- Extend the agent session presenter and DeepChat agent implementation with `compactSession`.
- Add a manual compaction preparation path in `CompactionService` that force-prepares an intent while
ignoring `autoCompactionEnabled`, threshold, and retain-recent-pairs checks.
- Execute manual compaction only for idle DeepChat runtime sessions; reuse the existing
`applyCompactionIntent` flow for persisted indicator messages and compaction events.
- Add a local DeepChat slash command item for idle existing sessions. Filter only this item during
generation.
- Intercept exact `/compact` submissions in `ChatPage` and call the session route instead of
`chat.sendMessage`.

## Compatibility

- No database schema migration is required.
- Existing summaries remain valid and are updated through the existing compare-and-set summary path.
- ACP sessions keep their existing command discovery and prompt forwarding behavior.

## Tests

- Unit-test manual intent creation in `CompactionService`.
- Unit-test `AgentRuntimePresenter.compactSession` success and unsupported states.
- Cover the typed route contract and route dispatcher.
- Cover renderer suggestion visibility and ChatPage command interception.
39 changes: 39 additions & 0 deletions docs/features/manual-compaction-command/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Manual Compaction Command Spec

> Status: Draft
> Date: 2026-05-12

## Background

DeepChat Agent sessions already auto-compact old conversation history when context pressure reaches
the configured threshold. Users sometimes know a session should be compacted before the automatic
threshold is reached, especially before switching tasks or continuing a long-running agent thread.

ACP agents manage their own command and context policy, so this feature is DeepChat Agent only.

## Goals

- Add a DeepChat Agent-only `/compact` slash command.
- Let users trigger the same compaction behavior as auto compaction without checking the current
threshold.
- Keep manual compaction available even when auto compaction is disabled.
- Hide `/compact` while a DeepChat session is generating, without disabling other slash suggestions.

## Acceptance Criteria

- In an idle DeepChat session, `/compact` appears in slash suggestions and triggers compaction.
- The command does not create a user message and is not sent to the model.
- Manual compaction reuses existing compaction messages, summary state updates, and renderer events.
- Manual compaction ignores `autoCompactionEnabled`, trigger threshold, and retain-recent-pairs
settings; those settings only affect automatic compaction.
- Manual compaction summarizes all eligible history after the current summary cursor, and only
returns no-op when there is no new persisted history to summarize.
- ACP sessions and new-thread drafts do not show or execute `/compact`.
- During generation, `/compact` is hidden and exact `/compact` submission is ignored; skills,
prompts, and tools remain available in slash suggestions.

## Non-Goals

- Add manual compaction controls outside the slash command.
- Change ACP command handling.
- Change the automatic compaction threshold behavior.
8 changes: 8 additions & 0 deletions docs/features/manual-compaction-command/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Manual Compaction Command Tasks

- [x] Add SDD artifacts.
- [x] Add shared route/schema/client support.
- [x] Add DeepChat runtime manual compaction.
- [x] Add renderer slash command and submit interception.
- [x] Update compaction copy and regression tests.
- [x] Run format, i18n, lint, and focused tests.
30 changes: 30 additions & 0 deletions src/main/presenter/agentRuntimePresenter/compactionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,36 @@ export class CompactionService {
})
}

async prepareForManualCompaction(params: {
sessionId: string
providerId: string
modelId: string
systemPrompt: string
contextLength: number
reserveTokens: number
extraReserveTokens?: number
supportsVision: boolean
preserveInterleavedReasoning: boolean
preserveEmptyInterleavedReasoning?: boolean
signal?: AbortSignal
}): Promise<CompactionIntent | null> {
throwIfAbortRequested(params.signal)

const historyRecords = this.messageStore
.getMessages(params.sessionId)
.filter(isContextHistoryRecord)
.sort((a, b) => a.orderSeq - b.orderSeq)

return this.prepareCompaction({
...params,
records: historyRecords,
protectedTurnCount: 0,
triggerThreshold: 0,
projectedMessages: [],
force: true
})
}

async applyCompaction(
intent: CompactionIntent,
signal?: AbortSignal
Expand Down
77 changes: 77 additions & 0 deletions src/main/presenter/agentRuntimePresenter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1609,6 +1609,83 @@ export class AgentRuntimePresenter implements IAgentImplementation {
return { ...persistedState }
}

async compactSession(
sessionId: string
): Promise<{ compacted: boolean; state: SessionCompactionState }> {
const state = this.runtimeState.get(sessionId) ?? (await this.getSessionListState(sessionId))
if (!state) {
throw new Error(`Session ${sessionId} not found`)
}
if (this.shouldBypassDeepChatContextBudget(state.providerId)) {
throw new Error('Manual compaction is only available for DeepChat agent sessions.')
}
if (state.status !== 'idle') {
throw new Error('Manual compaction is only available when the session is idle.')
}
if (this.hasPendingInteractions(sessionId)) {
throw new Error('Pending tool interactions must be resolved before compacting.')
}

this.setSessionStatus(sessionId, 'generating')
try {
const generationSettings = await this.getEffectiveSessionGenerationSettings(sessionId)
const interleavedReasoning = this.resolveInterleavedReasoningConfig(
state.providerId,
state.modelId,
generationSettings
)
const contextBudgetLength = this.resolveDeepChatContextBudgetLength(
state.providerId,
generationSettings.contextLength
)
const maxTokens = capAgentRequestMaxTokens(generationSettings.maxTokens, contextBudgetLength)
const activeSkillNames = await this.resolveActiveSkillNamesForToolProfile(sessionId)
const projectDir = this.resolveProjectDir(sessionId)
const tools = await this.loadToolDefinitionsForSession(
sessionId,
projectDir,
activeSkillNames
)
const toolReserveTokens = estimateToolReserveTokens(tools)
const baseSystemPrompt = await this.buildSystemPromptWithSkills(
sessionId,
generationSettings.systemPrompt,
tools,
activeSkillNames
)

const intent = await this.compactionService.prepareForManualCompaction({
sessionId,
providerId: state.providerId,
modelId: state.modelId,
systemPrompt: baseSystemPrompt,
contextLength: generationSettings.contextLength,
reserveTokens: maxTokens,
extraReserveTokens: toolReserveTokens,
supportsVision: this.supportsVision(state.providerId, state.modelId),
preserveInterleavedReasoning: interleavedReasoning.preserveReasoningContent,
preserveEmptyInterleavedReasoning:
interleavedReasoning.preserveEmptyReasoningContent === true
})

if (!intent) {
return {
compacted: false,
state: await this.getSessionCompactionState(sessionId)
}
}

const summaryState = await this.applyCompactionIntent(sessionId, intent)
const compacted = summaryState.summaryUpdatedAt !== intent.previousState.summaryUpdatedAt
return {
compacted,
state: await this.getSessionCompactionState(sessionId)
}
} finally {
this.setSessionStatus(sessionId, 'idle')
}
}

async clearMessages(sessionId: string): Promise<void> {
const state = await this.getSessionState(sessionId)
if (!state) {
Expand Down
23 changes: 23 additions & 0 deletions src/main/presenter/agentSessionPresenter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1330,6 +1330,29 @@ export class AgentSessionPresenter {
return await agent.getSessionCompactionState(sessionId)
}

async compactSession(
sessionId: string
): Promise<{ compacted: boolean; state: SessionCompactionState }> {
const session = this.sessionManager.get(sessionId)
if (!session) {
throw new Error(`Session not found: ${sessionId}`)
}

const agent = await this.resolveAgentImplementation(session.agentId)
if (!agent.compactSession) {
throw new Error(`Agent ${session.agentId} does not support manual compaction.`)
}

const agentType = await this.getAgentType(session.agentId)
const state = await agent.getSessionState(sessionId)
const providerId = state?.providerId || (agentType === 'acp' ? 'acp' : '')
if (agentType === 'acp' || providerId === 'acp') {
throw new Error('Manual compaction is only available for DeepChat agent sessions.')
}

return await agent.compactSession(sessionId)
}

async getSearchResults(messageId: string, searchId?: string): Promise<SearchResult[]> {
const normalizedMessageId = messageId?.trim()
if (!normalizedMessageId) {
Expand Down
7 changes: 7 additions & 0 deletions src/main/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ import {
providersUpdateRoute,
sessionsActivateRoute,
sessionsClearMessagesRoute,
sessionsCompactRoute,
sessionsConvertPendingInputToSteerRoute,
sessionsCreateRoute,
sessionsDeleteMessageRoute,
Expand Down Expand Up @@ -1711,6 +1712,12 @@ export async function dispatchDeepchatRoute(
return sessionsClearMessagesRoute.output.parse({ cleared: true })
}

case sessionsCompactRoute.name: {
const input = sessionsCompactRoute.input.parse(rawInput)
const result = await runtime.agentSessionPresenter.compactSession(input.sessionId)
return sessionsCompactRoute.output.parse(result)
}

case sessionsExportRoute.name: {
const input = sessionsExportRoute.input.parse(rawInput)
const result = await runtime.agentSessionPresenter.exportSession(
Expand Down
6 changes: 6 additions & 0 deletions src/renderer/api/SessionClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { DeepchatRouteInput } from '@shared/contracts/routes'
import {
sessionsActivateRoute,
sessionsClearMessagesRoute,
sessionsCompactRoute,
sessionsConvertPendingInputToSteerRoute,
sessionsCreateRoute,
sessionsDeleteMessageRoute,
Expand Down Expand Up @@ -255,6 +256,10 @@ export function createSessionClient(bridge: DeepchatBridge = getDeepchatBridge()
await bridge.invoke(sessionsClearMessagesRoute.name, { sessionId })
}

async function compactSession(sessionId: string) {
return await bridge.invoke(sessionsCompactRoute.name, { sessionId })
}

async function exportSession(
sessionId: string,
format: 'markdown' | 'html' | 'txt' | 'nowledge-mem'
Expand Down Expand Up @@ -458,6 +463,7 @@ export function createSessionClient(bridge: DeepchatBridge = getDeepchatBridge()
renameSession,
toggleSessionPinned,
clearSessionMessages,
compactSession,
exportSession,
deleteSession,
getAcpSessionCommands,
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/src/components/chat/ChatInputBox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ const props = withDefaults(
sessionId?: string | null
workspacePath?: string | null
isAcpSession?: boolean
isGenerating?: boolean
submitDisabled?: boolean
queueSubmitEnabled?: boolean
queueSubmitDisabled?: boolean
Expand All @@ -118,6 +119,7 @@ const props = withDefaults(
sessionId: null,
workspacePath: null,
isAcpSession: false,
isGenerating: false,
submitDisabled: false,
queueSubmitEnabled: false,
queueSubmitDisabled: false,
Expand Down Expand Up @@ -150,6 +152,8 @@ const mentions = useChatInputMentions({
workspacePath: computed(() => props.workspacePath),
sessionId: computed(() => props.sessionId),
isAcpSession: computed(() => props.isAcpSession),
isGenerating: computed(() => props.isGenerating),
compactCommandDescription: computed(() => t('chat.compaction.commandDescription')),
onCommandSubmit: (command) => emit('command-submit', command),
onActivateSkill: async (skillName) => {
await skillsData.activateSkill(skillName)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ import {
import SuggestionList from '../mentions/SuggestionList.vue'
import {
buildCommandText,
createManualCompactionSuggestion,
filterSlashSuggestionItems,
flattenPromptResultToText,
resolveSlashSelectionAction,
shouldShowManualCompactionCommand,
sortSlashSuggestionItems,
type AcpSessionCommand,
type SlashSuggestionItem
Expand All @@ -42,6 +44,8 @@ export interface UseChatInputMentionsOptions {
workspacePath: Ref<string | null>
sessionId: Ref<string | null>
isAcpSession: Ref<boolean>
isGenerating?: Ref<boolean>
compactCommandDescription?: Ref<string>
onCommandSubmit: (command: string) => void
onActivateSkill?: (skillName: string) => Promise<void> | void
onPendingSkillsChange?: (skills: string[]) => void
Expand Down Expand Up @@ -183,6 +187,15 @@ export function useChatInputMentions(options: UseChatInputMentionsOptions) {

const slashItems = computed<SlashSuggestionItem[]>(() => {
const items: SlashSuggestionItem[] = []
if (
shouldShowManualCompactionCommand({
sessionId: options.sessionId.value,
isAcpSession: options.isAcpSession.value,
isGenerating: options.isGenerating?.value
})
) {
items.push(createManualCompactionSuggestion(options.compactCommandDescription?.value ?? ''))
}

for (const command of acpCommands.value) {
items.push({
Expand Down
27 changes: 27 additions & 0 deletions src/renderer/src/components/chat/mentions/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ export type SlashActionDecision =
| { kind: 'insert-prompt'; prompt: PromptListEntry }
| { kind: 'request-prompt-args'; prompt: PromptListEntry }

export const MANUAL_COMPACTION_COMMAND_NAME = 'compact'
export const MANUAL_COMPACTION_COMMAND_TEXT = `/${MANUAL_COMPACTION_COMMAND_NAME}`

const uniq = (values: string[]) => {
const seen = new Set<string>()
const result: string[] = []
Expand All @@ -51,6 +54,30 @@ export const buildCommandText = (name: string, input?: string): string => {
return content ? `${base} ${content}` : base
}

export const isManualCompactionCommand = (value: string): boolean => {
return value.trim() === MANUAL_COMPACTION_COMMAND_TEXT
}

export const shouldShowManualCompactionCommand = (params: {
sessionId?: string | null
isAcpSession?: boolean
isGenerating?: boolean
}): boolean => {
return Boolean(params.sessionId) && params.isAcpSession !== true && params.isGenerating !== true
}

export const createManualCompactionSuggestion = (description: string): SlashSuggestionItem => ({
id: `command:${MANUAL_COMPACTION_COMMAND_NAME}`,
category: 'command',
label: MANUAL_COMPACTION_COMMAND_TEXT,
description,
payload: {
name: MANUAL_COMPACTION_COMMAND_NAME,
description,
input: null
}
})

const collectPromptSegments = (value: unknown, segments: string[], visited: Set<object>): void => {
if (typeof value === 'string') {
const text = value.trim()
Expand Down
8 changes: 6 additions & 2 deletions src/renderer/src/i18n/da-DK/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -277,8 +277,12 @@
}
},
"compaction": {
"compacting": "Komprimerer automatisk kontekst...",
"compacted": "Konteksten blev automatisk komprimeret"
"compacting": "Komprimerer kontekst...",
"compacted": "Konteksten blev komprimeret",
"commandDescription": "Komprimer samtalekontekst",
"noopTitle": "Ingen kontekst at komprimere",
"noopDescription": "Der er endnu ingen ny samtalehistorik at komprimere.",
"failedTitle": "Komprimering mislykkedes"
},
"newThread": {
"title": "Byg og udforsk"
Expand Down
Loading