From dacc278f444fb5a207baaf82bb6bab3eafe46d63 Mon Sep 17 00:00:00 2001 From: ethan Date: Wed, 19 Nov 2025 16:32:44 +1100 Subject: [PATCH 1/8] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20auto-compactio?= =?UTF-8?q?n=20when=20approaching=20context=20limits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Detect when history reaches 70% of model's context limit - Display warning banner before next message - Automatically trigger compaction and queue original message - Include original message as continueMessage in compaction prompt Auto-compaction flow: 1. After stream completes, count tokens in history 2. If >= 70% of max_input_tokens, set willCompactNext flag 3. Frontend displays warning banner 4. Next user message triggers compaction automatically 5. Original message queued and sent after compaction completes Implementation: - CompactionHandler: Token counting and threshold detection - AgentSession: Message interception and queueing - Shared compaction utility eliminates duplication - StreamingMessageAggregator: Track willCompactNext state - CompactionWarning component: User-facing banner Token counting is conservative (text only, no images) to leave margin for safety. Threshold set to 70% to ensure room for next exchange. --- src/browser/components/AIView.tsx | 3 + .../components/Messages/CompactionWarning.tsx | 9 ++ src/browser/stores/WorkspaceStore.ts | 2 + src/browser/utils/chatCommands.ts | 36 +++---- .../messages/StreamingMessageAggregator.ts | 11 +++ src/common/types/stream.ts | 5 +- src/common/utils/compaction.ts | 49 ++++++++++ src/node/services/agentSession.ts | 60 +++++++++++- src/node/services/compactionHandler.ts | 94 ++++++++++++++++++- src/node/services/streamManager.ts | 4 +- 10 files changed, 243 insertions(+), 30 deletions(-) create mode 100644 src/browser/components/Messages/CompactionWarning.tsx create mode 100644 src/common/utils/compaction.ts diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index 6efd7c040..14d301f04 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -4,6 +4,7 @@ import { MessageRenderer } from "./Messages/MessageRenderer"; import { InterruptedBarrier } from "./Messages/ChatBarrier/InterruptedBarrier"; import { StreamingBarrier } from "./Messages/ChatBarrier/StreamingBarrier"; import { RetryBarrier } from "./Messages/ChatBarrier/RetryBarrier"; +import { CompactionWarning } from "./Messages/CompactionWarning"; import { PinnedTodoList } from "./PinnedTodoList"; import { getAutoRetryKey, VIM_ENABLED_KEY } from "@/common/constants/storage"; import { ChatInput, type ChatInputAPI } from "./ChatInput/index"; @@ -447,6 +448,8 @@ const AIViewInner: React.FC = ({ })} {/* Show RetryBarrier after the last message if needed */} {showRetryBarrier && } + {/* Show CompactionWarning if next message will trigger auto-compaction */} + {workspaceState.willCompactNext && !isCompacting && } )} diff --git a/src/browser/components/Messages/CompactionWarning.tsx b/src/browser/components/Messages/CompactionWarning.tsx new file mode 100644 index 000000000..7ccaa5899 --- /dev/null +++ b/src/browser/components/Messages/CompactionWarning.tsx @@ -0,0 +1,9 @@ +import React from "react"; + +export const CompactionWarning: React.FC = () => { + return ( +
+ ⚠️ Approaching context limit. Next message will trigger auto-compaction. +
+ ); +}; diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index ce447a3cf..6c4febc6c 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -37,6 +37,7 @@ export interface WorkspaceState { todos: TodoItem[]; agentStatus: { emoji: string; message: string; url?: string } | undefined; pendingStreamStartTime: number | null; + willCompactNext: boolean; } /** @@ -334,6 +335,7 @@ export class WorkspaceStore { todos: aggregator.getCurrentTodos(), agentStatus: aggregator.getAgentStatus(), pendingStreamStartTime: aggregator.getPendingStreamStartTime(), + willCompactNext: aggregator.willCompactOnNextMessage(), }; }); } diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index 39f63800b..ad6f2db84 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -7,7 +7,7 @@ */ import type { SendMessageOptions } from "@/common/types/ipc"; -import type { MuxFrontendMetadata, CompactionRequestData } from "@/common/types/message"; +import type { MuxFrontendMetadata } from "@/common/types/message"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import type { RuntimeConfig } from "@/common/types/runtime"; import { RUNTIME_MODE, SSH_RUNTIME_PREFIX } from "@/common/types/runtime"; @@ -17,6 +17,7 @@ import type { ParsedCommand } from "@/browser/utils/slashCommands/types"; import { applyCompactionOverrides } from "@/browser/utils/messages/compactionOptions"; import { resolveCompactionModel } from "@/browser/utils/messages/compactionModelPreference"; import { getRuntimeKey } from "@/common/constants/storage"; +import { prepareCompactionMessage as prepareCompactionMessageCommon } from "@/common/utils/compaction"; // ============================================================================ // Workspace Creation @@ -197,33 +198,26 @@ export function prepareCompactionMessage(options: CompactionOptions): { metadata: MuxFrontendMetadata; sendOptions: SendMessageOptions; } { - const targetWords = options.maxOutputTokens ? Math.round(options.maxOutputTokens / 1.3) : 2000; - - // Build compaction message with optional continue context - let messageText = `Summarize this conversation into a compact form for a new Assistant to continue helping the user. Use approximately ${targetWords} words.`; - - if (options.continueMessage) { - messageText += `\n\nThe user wants to continue with: ${options.continueMessage}`; - } - // Handle model preference (sticky globally) const effectiveModel = resolveCompactionModel(options.model); - // Create compaction metadata (will be stored in user message) - const compactData: CompactionRequestData = { - model: effectiveModel, + // Use common compaction message preparation + const { messageText, metadata } = prepareCompactionMessageCommon({ maxOutputTokens: options.maxOutputTokens, + model: effectiveModel, continueMessage: options.continueMessage, - }; - - const metadata: MuxFrontendMetadata = { - type: "compaction-request", rawCommand: formatCompactionCommand(options), - parsed: compactData, - }; + }); - // Apply compaction overrides - const sendOptions = applyCompactionOverrides(options.sendMessageOptions, compactData); + // Apply compaction overrides (metadata is always compaction-request type here) + const compactionMetadata = metadata as Extract< + MuxFrontendMetadata, + { type: "compaction-request" } + >; + const sendOptions = applyCompactionOverrides( + options.sendMessageOptions, + compactionMetadata.parsed + ); return { messageText, metadata, sendOptions }; } diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index e0d1193e1..9d07dc4c6 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -104,6 +104,9 @@ export class StreamingMessageAggregator { // REQUIRED: Backend guarantees every workspace has createdAt via config.ts private readonly createdAt: string; + // Flag indicating if next user message will trigger auto-compaction + private willCompactNext = false; + constructor(createdAt: string) { this.createdAt = createdAt; this.updateRecency(); @@ -294,6 +297,10 @@ export class StreamingMessageAggregator { return false; } + willCompactOnNextMessage(): boolean { + return this.willCompactNext && !this.isCompacting(); + } + getCurrentModel(): string | undefined { // If there's an active stream, return its model for (const context of this.activeStreams.values()) { @@ -455,6 +462,10 @@ export class StreamingMessageAggregator { // Clean up stream-scoped state (active stream tracking, TODOs) this.cleanupStreamState(data.messageId); } + + // Update willCompactNext flag from metadata + this.willCompactNext = data.metadata.willCompactOnNextMessage ?? false; + this.invalidateCache(); } diff --git a/src/common/types/stream.ts b/src/common/types/stream.ts index 4639329a7..9647aac0b 100644 --- a/src/common/types/stream.ts +++ b/src/common/types/stream.ts @@ -42,6 +42,7 @@ export interface StreamEndEvent { systemMessageTokens?: number; historySequence?: number; // Present when loading from history timestamp?: number; // Present when loading from history + willCompactOnNextMessage?: boolean; // True if next user message will trigger auto-compaction }; // Parts array preserves temporal ordering of reasoning, text, and tool calls parts: CompletedMessagePart[]; @@ -52,10 +53,12 @@ export interface StreamAbortEvent { workspaceId: string; messageId: string; // Metadata may contain usage if abort occurred after stream completed processing - metadata?: { + metadata: { usage?: LanguageModelV2Usage; duration?: number; + willCompactOnNextMessage?: boolean; }; + willCompactOnNextMessage?: boolean; abandonPartial?: boolean; } diff --git a/src/common/utils/compaction.ts b/src/common/utils/compaction.ts new file mode 100644 index 000000000..2ec29ed1c --- /dev/null +++ b/src/common/utils/compaction.ts @@ -0,0 +1,49 @@ +/** + * Shared compaction utilities for both frontend and backend + */ + +import type { MuxFrontendMetadata, CompactionRequestData } from "@/common/types/message"; + +export interface PrepareCompactionMessageOptions { + maxOutputTokens?: number; + model?: string; + rawCommand: string; + continueMessage?: string; +} + +export interface PrepareCompactionMessageResult { + messageText: string; + metadata: MuxFrontendMetadata; +} + +/** + * Prepare compaction message text and metadata + * Used by both frontend (slash commands) and backend (auto-compaction) + */ +export function prepareCompactionMessage( + options: PrepareCompactionMessageOptions +): PrepareCompactionMessageResult { + const targetWords = options.maxOutputTokens ? Math.round(options.maxOutputTokens / 1.3) : 2000; + + // Build compaction message with optional continue context + let messageText = `Summarize this conversation into a compact form for a new Assistant to continue helping the user. Use approximately ${targetWords} words.`; + + if (options.continueMessage) { + messageText += `\n\nThe user wants to continue with: ${options.continueMessage}`; + } + + // Create compaction metadata + const compactData: CompactionRequestData = { + model: options.model, + maxOutputTokens: options.maxOutputTokens, + continueMessage: options.continueMessage, + }; + + const metadata: MuxFrontendMetadata = { + type: "compaction-request", + rawCommand: options.rawCommand, + parsed: compactData, + }; + + return { messageText, metadata }; +} diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index adbe96ee3..2d7c28b60 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -25,6 +25,7 @@ import { createRuntime } from "@/node/runtime/runtimeFactory"; import { MessageQueue } from "./messageQueue"; import type { StreamEndEvent, StreamAbortEvent } from "@/common/types/stream"; import { CompactionHandler } from "./compactionHandler"; +import { prepareCompactionMessage } from "@/common/utils/compaction"; export interface AgentSessionChatEvent { workspaceId: string; @@ -270,6 +271,43 @@ export class AgentSession { ); } + // Auto-compaction: intercept if flag is set + if ( + this.compactionHandler.getWillCompactNext() && + options?.muxMetadata?.type !== "compaction-request" + ) { + this.compactionHandler.clearWillCompactNext(); + + if (!options?.model) { + return Err(createUnknownSendMessageError("No model specified for auto-compaction.")); + } + + // Prepare compaction with continueMessage + const { messageText, metadata } = prepareCompactionMessage({ + model: options.model, + rawCommand: "/compact", + continueMessage: trimmedMessage, + }); + + const compactionOptions = { + model: options.model, + thinkingLevel: options.thinkingLevel, + toolPolicy: options.toolPolicy, + additionalSystemInstructions: options.additionalSystemInstructions, + mode: options.mode, + muxMetadata: metadata, + }; + + return this.sendMessage(messageText, compactionOptions); + } + + if ( + this.compactionHandler.getWillCompactNext() && + options?.muxMetadata?.type === "compaction-request" + ) { + this.compactionHandler.clearWillCompactNext(); + } + if (options?.editMessageId) { const truncateResult = await this.historyService.truncateAfterMessage( this.workspaceId, @@ -438,18 +476,32 @@ export class AgentSession { forward("reasoning-end", (payload) => this.emitChatEvent(payload)); forward("stream-end", async (payload) => { - const handled = await this.compactionHandler.handleCompletion(payload as StreamEndEvent); + const event = payload as StreamEndEvent; + const handled = await this.compactionHandler.handleCompletion(event); if (!handled) { - this.emitChatEvent(payload); + const shouldCompact = await this.compactionHandler.checkAndUpdateAutoCompactionFlag(); + if (shouldCompact) { + event.metadata.willCompactOnNextMessage = true; + } + this.emitChatEvent(event); } // Stream end: auto-send queued messages this.sendQueuedMessages(); }); forward("stream-abort", async (payload) => { - const handled = await this.compactionHandler.handleAbort(payload as StreamAbortEvent); + const event = payload as StreamAbortEvent; + const handled = await this.compactionHandler.handleAbort(event); if (!handled) { - this.emitChatEvent(payload); + const shouldCompact = await this.compactionHandler.checkAndUpdateAutoCompactionFlag(); + if (shouldCompact) { +<<<<<<< Updated upstream + event.willCompactOnNextMessage = true; +======= + event.metadata.willCompactOnNextMessage = true; +>>>>>>> Stashed changes + } + this.emitChatEvent(event); } // Stream aborted: restore queued messages to input diff --git a/src/node/services/compactionHandler.ts b/src/node/services/compactionHandler.ts index 5787a65fe..9e049c7d2 100644 --- a/src/node/services/compactionHandler.ts +++ b/src/node/services/compactionHandler.ts @@ -7,7 +7,13 @@ import { Ok, Err } from "@/common/types/result"; import type { LanguageModelV2Usage } from "@ai-sdk/provider"; import { cumUsageHistory } from "@/common/utils/tokens/displayUsage"; import { sumUsageHistory } from "@/common/utils/tokens/usageAggregator"; -import { createMuxMessage, MuxMessage } from "@/common/types/message"; +<<<<<<< Updated upstream +import { createMuxMessage, type MuxMessage } from "@/common/types/message"; +======= +import { createMuxMessage } from "@/common/types/message"; +>>>>>>> Stashed changes +import { getModelStats } from "@/common/utils/tokens/modelStats"; +import { getTokenizerForModel } from "@/node/utils/main/tokenizer"; interface CompactionHandlerOptions { workspaceId: string; @@ -23,12 +29,14 @@ interface CompactionHandlerOptions { * - Handling Ctrl+C (cancel) and Ctrl+A (accept early) flows * - Replacing chat history with compacted summaries * - Preserving cumulative usage across compactions + * - Auto-compaction detection when approaching context limits */ export class CompactionHandler { private readonly workspaceId: string; private readonly historyService: HistoryService; private readonly emitter: EventEmitter; private readonly processedCompactionRequestIds: Set = new Set(); + private willCompactNext = false; constructor(options: CompactionHandlerOptions) { this.workspaceId = options.workspaceId; @@ -130,7 +138,7 @@ export class CompactionHandler { // Mark as processed before performing compaction this.processedCompactionRequestIds.add(lastUserMsg.id); - const result = await this.performCompaction(summary, messages,event.metadata); + const result = await this.performCompaction(summary, messages, event.metadata); if (!result.success) { console.error("[CompactionHandler] Compaction failed:", result.error); return false; @@ -215,6 +223,88 @@ export class CompactionHandler { return Ok(undefined); } + /** + * Check if history is approaching context limit and should trigger auto-compaction + * Returns true if tokens >= 70% of model's max_input_tokens + */ + private async shouldTriggerAutoCompaction(): Promise { + const historyResult = await this.historyService.getHistory(this.workspaceId); + if (!historyResult.success) { + return false; + } + + const messages = historyResult.data; + if (messages.length === 0) { + return false; + } + + // Get model from last assistant message + const lastAssistantMsg = [...messages].reverse().find((m) => m.role === "assistant"); + const model = lastAssistantMsg?.metadata?.model; + if (!model) { + return false; + } + + // Get model stats for max_input_tokens + const modelStats = getModelStats(model); + const maxInputTokens = modelStats?.max_input_tokens; + if (!maxInputTokens) { + // If we don't have token limits for this model, don't trigger auto-compaction + return false; + } + + // Count tokens in entire history + const tokenizer = await getTokenizerForModel(model); + let totalTokens = 0; + + for (const message of messages) { + // Count text content + const textContent = message.parts + .filter((part): part is { type: "text"; text: string } => part.type === "text") + .map((part) => part.text) + .join(""); + + if (textContent) { + totalTokens += await tokenizer.countTokens(textContent); + } + + // Note: We're not counting image tokens here as they're more complex + // This is a conservative estimate - if we're at 70% of text tokens, + // we're likely closer to the limit when images are included + } + + // Trigger if we're at or above 70% of the limit + const threshold = maxInputTokens * 0.7; + return totalTokens >= threshold; + } + + /** + * Check if auto-compaction should trigger and update the flag + * Returns true if the flag was set (indicating frontend should show warning) + */ + async checkAndUpdateAutoCompactionFlag(): Promise { + const shouldCompact = await this.shouldTriggerAutoCompaction(); + if (shouldCompact) { + this.willCompactNext = true; + return true; + } + return false; + } + + /** + * Get the auto-compaction flag state + */ + getWillCompactNext(): boolean { + return this.willCompactNext; + } + + /** + * Clear the auto-compaction flag + */ + clearWillCompactNext(): void { + this.willCompactNext = false; + } + /** * Emit chat event through the session's emitter */ diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index 0c3475340..049d7cb99 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -865,8 +865,6 @@ export class StreamManager extends EventEmitter { parts: streamInfo.parts, // Parts array with temporal ordering (includes reasoning) }; - this.emit("stream-end", streamEndEvent); - // Update history with final message (only if there are parts) if (streamInfo.parts && streamInfo.parts.length > 0) { const finalAssistantMessage: MuxMessage = { @@ -886,6 +884,8 @@ export class StreamManager extends EventEmitter { // Update the placeholder message in chat.jsonl with final content await this.historyService.updateHistory(workspaceId as string, finalAssistantMessage); } + + this.emit("stream-end", streamEndEvent); } } catch (error) { streamInfo.state = StreamState.ERROR; From 641e9740ae55ecb7ce307b911e6f31a4ee5343cb Mon Sep 17 00:00:00 2001 From: ethan Date: Wed, 19 Nov 2025 16:35:44 +1100 Subject: [PATCH 2/8] fixup --- src/common/types/stream.ts | 1 - src/node/services/agentSession.ts | 4 ---- src/node/services/compactionHandler.ts | 4 ---- 3 files changed, 9 deletions(-) diff --git a/src/common/types/stream.ts b/src/common/types/stream.ts index 9647aac0b..1e7b8f632 100644 --- a/src/common/types/stream.ts +++ b/src/common/types/stream.ts @@ -56,7 +56,6 @@ export interface StreamAbortEvent { metadata: { usage?: LanguageModelV2Usage; duration?: number; - willCompactOnNextMessage?: boolean; }; willCompactOnNextMessage?: boolean; abandonPartial?: boolean; diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 2d7c28b60..2e6f65ea6 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -495,11 +495,7 @@ export class AgentSession { if (!handled) { const shouldCompact = await this.compactionHandler.checkAndUpdateAutoCompactionFlag(); if (shouldCompact) { -<<<<<<< Updated upstream event.willCompactOnNextMessage = true; -======= - event.metadata.willCompactOnNextMessage = true; ->>>>>>> Stashed changes } this.emitChatEvent(event); } diff --git a/src/node/services/compactionHandler.ts b/src/node/services/compactionHandler.ts index 9e049c7d2..09fcc7084 100644 --- a/src/node/services/compactionHandler.ts +++ b/src/node/services/compactionHandler.ts @@ -7,11 +7,7 @@ import { Ok, Err } from "@/common/types/result"; import type { LanguageModelV2Usage } from "@ai-sdk/provider"; import { cumUsageHistory } from "@/common/utils/tokens/displayUsage"; import { sumUsageHistory } from "@/common/utils/tokens/usageAggregator"; -<<<<<<< Updated upstream import { createMuxMessage, type MuxMessage } from "@/common/types/message"; -======= -import { createMuxMessage } from "@/common/types/message"; ->>>>>>> Stashed changes import { getModelStats } from "@/common/utils/tokens/modelStats"; import { getTokenizerForModel } from "@/node/utils/main/tokenizer"; From 4224718668c3a05b60ffc120d4021f90a8640b9a Mon Sep 17 00:00:00 2001 From: ethan Date: Wed, 19 Nov 2025 17:59:37 +1100 Subject: [PATCH 3/8] fix: queue images during auto-compaction --- src/browser/components/ChatInput/index.tsx | 67 ++++++++++++---------- src/browser/utils/chatCommands.ts | 15 ++++- src/common/types/message.ts | 10 +++- src/common/utils/compaction.ts | 6 +- src/node/services/agentSession.ts | 10 +++- 5 files changed, 67 insertions(+), 41 deletions(-) diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 20bbb1827..e5e3bc3ef 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -455,6 +455,38 @@ export const ChatInput: React.FC = (props) => { const messageText = input.trim(); + // Prepare image parts once for reuse + const imageParts = + imageAttachments.length > 0 + ? imageAttachments.map((img, index) => { + // Validate before sending to help with debugging + if (!img.url || typeof img.url !== "string") { + console.error( + `Image attachment [${index}] has invalid url:`, + typeof img.url, + img.url?.slice(0, 50) + ); + } + if (!img.url?.startsWith("data:")) { + console.error( + `Image attachment [${index}] url is not a data URL:`, + img.url?.slice(0, 100) + ); + } + if (!img.mediaType || typeof img.mediaType !== "string") { + console.error( + `Image attachment [${index}] has invalid mediaType:`, + typeof img.mediaType, + img.mediaType + ); + } + return { + url: img.url, + mediaType: img.mediaType, + }; + }) + : undefined; + // Route to creation handler for creation variant if (variant === "creation") { // Creation variant: simple message send + workspace creation @@ -567,8 +599,10 @@ export const ChatInput: React.FC = (props) => { const context: CommandHandlerContext = { workspaceId: props.workspaceId, sendMessageOptions, + imageParts, editMessageId: editingMessage?.id, setInput, + setImageAttachments, setIsSending, setToast, onCancelEdit: props.onCancelEdit, @@ -633,6 +667,7 @@ export const ChatInput: React.FC = (props) => { workspaceId: props.workspaceId, sendMessageOptions, setInput, + setImageAttachments, setIsSending, setToast, }; @@ -659,35 +694,6 @@ export const ChatInput: React.FC = (props) => { const previousImageAttachments = [...imageAttachments]; try { - // Prepare image parts if any - const imageParts = imageAttachments.map((img, index) => { - // Validate before sending to help with debugging - if (!img.url || typeof img.url !== "string") { - console.error( - `Image attachment [${index}] has invalid url:`, - typeof img.url, - img.url?.slice(0, 50) - ); - } - if (!img.url?.startsWith("data:")) { - console.error( - `Image attachment [${index}] url is not a data URL:`, - img.url?.slice(0, 100) - ); - } - if (!img.mediaType || typeof img.mediaType !== "string") { - console.error( - `Image attachment [${index}] has invalid mediaType:`, - typeof img.mediaType, - img.mediaType - ); - } - return { - url: img.url, - mediaType: img.mediaType, - }; - }); - // When editing a /compact command, regenerate the actual summarization request let actualMessageText = messageText; let muxMetadata: MuxFrontendMetadata | undefined; @@ -704,6 +710,7 @@ export const ChatInput: React.FC = (props) => { workspaceId: props.workspaceId, maxOutputTokens: parsed.maxOutputTokens, continueMessage: parsed.continueMessage, + imageParts, model: parsed.model, sendMessageOptions, }); @@ -729,7 +736,7 @@ export const ChatInput: React.FC = (props) => { ...sendMessageOptions, ...compactionOptions, editMessageId: editingMessage?.id, - imageParts: imageParts.length > 0 ? imageParts : undefined, + imageParts, muxMetadata, } ); diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index ad6f2db84..46cf9ba9d 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -6,7 +6,7 @@ * to ensure consistent behavior and avoid duplication. */ -import type { SendMessageOptions } from "@/common/types/ipc"; +import type { SendMessageOptions, ImagePart } from "@/common/types/ipc"; import type { MuxFrontendMetadata } from "@/common/types/message"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import type { RuntimeConfig } from "@/common/types/runtime"; @@ -18,6 +18,7 @@ import { applyCompactionOverrides } from "@/browser/utils/messages/compactionOpt import { resolveCompactionModel } from "@/browser/utils/messages/compactionModelPreference"; import { getRuntimeKey } from "@/common/constants/storage"; import { prepareCompactionMessage as prepareCompactionMessageCommon } from "@/common/utils/compaction"; +import { ImageAttachment } from "../components/ImageAttachments"; // ============================================================================ // Workspace Creation @@ -178,7 +179,8 @@ export { forkWorkspace } from "./workspaceFork"; export interface CompactionOptions { workspaceId: string; maxOutputTokens?: number; - continueMessage?: string; + continueMessage?: string; // Frontend receives string from slash command parser + imageParts?: ImagePart[]; // Images attached to the continue message model?: string; sendMessageOptions: SendMessageOptions; editMessageId?: string; @@ -205,7 +207,9 @@ export function prepareCompactionMessage(options: CompactionOptions): { const { messageText, metadata } = prepareCompactionMessageCommon({ maxOutputTokens: options.maxOutputTokens, model: effectiveModel, - continueMessage: options.continueMessage, + continueMessage: options.continueMessage + ? { text: options.continueMessage, imageParts: options.imageParts } + : undefined, rawCommand: formatCompactionCommand(options), }); @@ -273,8 +277,10 @@ function formatCompactionCommand(options: CompactionOptions): string { export interface CommandHandlerContext { workspaceId: string; sendMessageOptions: SendMessageOptions; + imageParts?: ImagePart[]; // Images attached when command was invoked editMessageId?: string; setInput: (value: string) => void; + setImageAttachments: (images: ImageAttachment[]) => void; setIsSending: (value: boolean) => void; setToast: (toast: Toast) => void; onCancelEdit?: () => void; @@ -388,12 +394,14 @@ export async function handleCompactCommand( sendMessageOptions, editMessageId, setInput, + setImageAttachments, setIsSending, setToast, onCancelEdit, } = context; setInput(""); + setImageAttachments([]); setIsSending(true); try { @@ -401,6 +409,7 @@ export async function handleCompactCommand( workspaceId, maxOutputTokens: parsed.maxOutputTokens, continueMessage: parsed.continueMessage, + imageParts: context.imageParts, model: parsed.model, sendMessageOptions, editMessageId, diff --git a/src/common/types/message.ts b/src/common/types/message.ts index 0d88b52d4..bc62e1294 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -5,11 +5,17 @@ import type { ToolPolicy } from "@/common/utils/tools/toolPolicy"; import type { ChatUsageDisplay } from "@/common/utils/tokens/usageAggregator"; import type { ImagePart } from "./ipc"; +// Message to continue with after compaction +export interface ContinueMessage { + text: string; + imageParts?: ImagePart[]; +} + // Parsed compaction request data (shared type for consistency) export interface CompactionRequestData { model?: string; // Custom model override for compaction maxOutputTokens?: number; - continueMessage?: string; + continueMessage?: ContinueMessage; } // Frontend-specific metadata stored in muxMetadata field @@ -102,7 +108,7 @@ export type DisplayedMessage = rawCommand: string; parsed: { maxOutputTokens?: number; - continueMessage?: string; + continueMessage?: ContinueMessage; }; }; } diff --git a/src/common/utils/compaction.ts b/src/common/utils/compaction.ts index 2ec29ed1c..7e88d8e33 100644 --- a/src/common/utils/compaction.ts +++ b/src/common/utils/compaction.ts @@ -2,13 +2,13 @@ * Shared compaction utilities for both frontend and backend */ -import type { MuxFrontendMetadata, CompactionRequestData } from "@/common/types/message"; +import type { MuxFrontendMetadata, CompactionRequestData, ContinueMessage } from "@/common/types/message"; export interface PrepareCompactionMessageOptions { maxOutputTokens?: number; model?: string; rawCommand: string; - continueMessage?: string; + continueMessage?: ContinueMessage; } export interface PrepareCompactionMessageResult { @@ -29,7 +29,7 @@ export function prepareCompactionMessage( let messageText = `Summarize this conversation into a compact form for a new Assistant to continue helping the user. Use approximately ${targetWords} words.`; if (options.continueMessage) { - messageText += `\n\nThe user wants to continue with: ${options.continueMessage}`; + messageText += `\n\nThe user wants to continue with: ${options.continueMessage.text}`; } // Create compaction metadata diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 2e6f65ea6..79a57d993 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -285,8 +285,9 @@ export class AgentSession { // Prepare compaction with continueMessage const { messageText, metadata } = prepareCompactionMessage({ model: options.model, - rawCommand: "/compact", - continueMessage: trimmedMessage, + // To match a manual compaction with a continue message + rawCommand: `/compact\n${trimmedMessage}`, + continueMessage: { text: trimmedMessage, imageParts } }); const compactionOptions = { @@ -366,7 +367,10 @@ export class AgentSession { if (muxMeta?.type === "compaction-request" && muxMeta.parsed.continueMessage && options) { // Strip out edit-specific and compaction-specific fields so the queued message is a fresh user message const { muxMetadata, mode, editMessageId, ...continueOptions } = options; - this.messageQueue.add(muxMeta.parsed.continueMessage, continueOptions); + this.messageQueue.add(muxMeta.parsed.continueMessage.text, { + ...continueOptions, + imageParts: muxMeta.parsed.continueMessage.imageParts, + }); this.emitQueuedMessageChanged(); } From 8d957b13142d5092caa65b87318bb63730467e51 Mon Sep 17 00:00:00 2001 From: ethan Date: Wed, 19 Nov 2025 18:05:54 +1100 Subject: [PATCH 4/8] fix: display will compact warning on stream interrupt too --- src/browser/utils/messages/StreamingMessageAggregator.ts | 4 +++- src/common/types/stream.ts | 2 +- src/node/services/agentSession.ts | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index 9d07dc4c6..38ab5b2c6 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -464,7 +464,7 @@ export class StreamingMessageAggregator { } // Update willCompactNext flag from metadata - this.willCompactNext = data.metadata.willCompactOnNextMessage ?? false; + this.willCompactNext = data.willCompactOnNextMessage ?? false; this.invalidateCache(); } @@ -484,6 +484,8 @@ export class StreamingMessageAggregator { }; } + this.willCompactNext = data.willCompactOnNextMessage ?? false; + // Clean up stream-scoped state (active stream tracking, TODOs) this.cleanupStreamState(data.messageId); this.invalidateCache(); diff --git a/src/common/types/stream.ts b/src/common/types/stream.ts index 1e7b8f632..cbce95034 100644 --- a/src/common/types/stream.ts +++ b/src/common/types/stream.ts @@ -42,10 +42,10 @@ export interface StreamEndEvent { systemMessageTokens?: number; historySequence?: number; // Present when loading from history timestamp?: number; // Present when loading from history - willCompactOnNextMessage?: boolean; // True if next user message will trigger auto-compaction }; // Parts array preserves temporal ordering of reasoning, text, and tool calls parts: CompletedMessagePart[]; + willCompactOnNextMessage?: boolean; // True if next user message will trigger auto-compaction } export interface StreamAbortEvent { diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 79a57d993..e68123121 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -485,7 +485,7 @@ export class AgentSession { if (!handled) { const shouldCompact = await this.compactionHandler.checkAndUpdateAutoCompactionFlag(); if (shouldCompact) { - event.metadata.willCompactOnNextMessage = true; + event.willCompactOnNextMessage = true; } this.emitChatEvent(event); } From 6a9e05f91e8c5d9486994a6994822e8db8102be3 Mon Sep 17 00:00:00 2001 From: ethan Date: Wed, 19 Nov 2025 19:01:25 +1100 Subject: [PATCH 5/8] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20merge=20compacti?= =?UTF-8?q?on=20files=20into=20single=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Consolidate applyCompactionOverrides into compaction.ts - Merge all compaction tests into single test file (16 tests) - Update imports in useResumeManager - Remove duplicate compactionOptions files Net: -76 lines, cleaner organization of related functions --- src/browser/components/ChatInput/index.tsx | 23 +- src/browser/hooks/useResumeManager.ts | 2 +- src/browser/utils/chatCommands.ts | 41 +--- .../utils/messages/compactionOptions.test.ts | 92 ------- .../utils/messages/compactionOptions.ts | 38 --- src/common/utils/compaction.test.ts | 228 ++++++++++++++++++ src/common/utils/compaction.ts | 86 +++++-- src/node/services/agentSession.ts | 22 +- 8 files changed, 328 insertions(+), 204 deletions(-) delete mode 100644 src/browser/utils/messages/compactionOptions.test.ts delete mode 100644 src/browser/utils/messages/compactionOptions.ts create mode 100644 src/common/utils/compaction.test.ts diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index e5e3bc3ef..b87588018 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -29,9 +29,10 @@ import { handleNewCommand, handleCompactCommand, forkWorkspace, - prepareCompactionMessage, type CommandHandlerContext, } from "@/browser/utils/chatCommands"; +import { createCompactionRequest } from "@/common/utils/compaction"; +import { resolveCompactionModel } from "@/browser/utils/messages/compactionModelPreference"; import { CUSTOM_EVENTS } from "@/common/constants/events"; import { getSlashCommandSuggestions, @@ -702,17 +703,23 @@ export const ChatInput: React.FC = (props) => { if (editingMessage && messageText.startsWith("/")) { const parsed = parseCommand(messageText); if (parsed?.type === "compact") { + // Resolve model with sticky preference handling + const effectiveModel = resolveCompactionModel(parsed.model) ?? sendMessageOptions.model; + const { messageText: regeneratedText, metadata, sendOptions, - } = prepareCompactionMessage({ - workspaceId: props.workspaceId, - maxOutputTokens: parsed.maxOutputTokens, - continueMessage: parsed.continueMessage, - imageParts, - model: parsed.model, - sendMessageOptions, + } = createCompactionRequest({ + baseOptions: { + ...sendMessageOptions, + model: effectiveModel, + maxOutputTokens: parsed.maxOutputTokens, + }, + continueMessage: parsed.continueMessage + ? { text: parsed.continueMessage, imageParts } + : undefined, + rawCommand: messageText, }); actualMessageText = regeneratedText; muxMetadata = metadata; diff --git a/src/browser/hooks/useResumeManager.ts b/src/browser/hooks/useResumeManager.ts index 507ab7523..4fa29fde4 100644 --- a/src/browser/hooks/useResumeManager.ts +++ b/src/browser/hooks/useResumeManager.ts @@ -8,7 +8,7 @@ import { isEligibleForAutoRetry, isNonRetryableSendError, } from "@/browser/utils/messages/retryEligibility"; -import { applyCompactionOverrides } from "@/browser/utils/messages/compactionOptions"; +import { applyCompactionOverrides } from "@/common/utils/compaction"; import type { SendMessageError } from "@/common/types/errors"; import { createFailedRetryState, diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index 46cf9ba9d..80982a573 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -14,10 +14,9 @@ import { RUNTIME_MODE, SSH_RUNTIME_PREFIX } from "@/common/types/runtime"; import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; import type { Toast } from "@/browser/components/ChatInputToast"; import type { ParsedCommand } from "@/browser/utils/slashCommands/types"; -import { applyCompactionOverrides } from "@/browser/utils/messages/compactionOptions"; import { resolveCompactionModel } from "@/browser/utils/messages/compactionModelPreference"; import { getRuntimeKey } from "@/common/constants/storage"; -import { prepareCompactionMessage as prepareCompactionMessageCommon } from "@/common/utils/compaction"; +import { createCompactionRequest } from "@/common/utils/compaction"; import { ImageAttachment } from "../components/ImageAttachments"; // ============================================================================ @@ -192,49 +191,23 @@ export interface CompactionResult { } /** - * Prepare compaction message from options - * Returns the actual message text (summarization request), metadata, and options + * Execute a compaction command */ -export function prepareCompactionMessage(options: CompactionOptions): { - messageText: string; - metadata: MuxFrontendMetadata; - sendOptions: SendMessageOptions; -} { +export async function executeCompaction(options: CompactionOptions): Promise { // Handle model preference (sticky globally) - const effectiveModel = resolveCompactionModel(options.model); + const effectiveModel = resolveCompactionModel(options.model) ?? options.sendMessageOptions.model; - // Use common compaction message preparation - const { messageText, metadata } = prepareCompactionMessageCommon({ - maxOutputTokens: options.maxOutputTokens, - model: effectiveModel, + // Use shared factory to create compaction request with proper overrides + const { messageText, sendOptions } = createCompactionRequest({ + baseOptions: { ...options.sendMessageOptions, model: effectiveModel }, continueMessage: options.continueMessage ? { text: options.continueMessage, imageParts: options.imageParts } : undefined, rawCommand: formatCompactionCommand(options), }); - // Apply compaction overrides (metadata is always compaction-request type here) - const compactionMetadata = metadata as Extract< - MuxFrontendMetadata, - { type: "compaction-request" } - >; - const sendOptions = applyCompactionOverrides( - options.sendMessageOptions, - compactionMetadata.parsed - ); - - return { messageText, metadata, sendOptions }; -} - -/** - * Execute a compaction command - */ -export async function executeCompaction(options: CompactionOptions): Promise { - const { messageText, metadata, sendOptions } = prepareCompactionMessage(options); - const result = await window.api.workspace.sendMessage(options.workspaceId, messageText, { ...sendOptions, - muxMetadata: metadata, editMessageId: options.editMessageId, }); diff --git a/src/browser/utils/messages/compactionOptions.test.ts b/src/browser/utils/messages/compactionOptions.test.ts deleted file mode 100644 index dd5efd6c5..000000000 --- a/src/browser/utils/messages/compactionOptions.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Tests for compaction options transformation - */ - -import { applyCompactionOverrides } from "./compactionOptions"; -import type { SendMessageOptions } from "@/common/types/ipc"; -import type { CompactionRequestData } from "@/common/types/message"; -import { KNOWN_MODELS } from "@/common/constants/knownModels"; - -describe("applyCompactionOverrides", () => { - const baseOptions: SendMessageOptions = { - model: KNOWN_MODELS.SONNET.id, - thinkingLevel: "medium", - toolPolicy: [], - mode: "exec", - }; - - it("uses workspace model when no override specified", () => { - const compactData: CompactionRequestData = {}; - const result = applyCompactionOverrides(baseOptions, compactData); - - expect(result.model).toBe(KNOWN_MODELS.SONNET.id); - expect(result.mode).toBe("compact"); - }); - - it("applies custom model override", () => { - const compactData: CompactionRequestData = { - model: KNOWN_MODELS.HAIKU.id, - }; - const result = applyCompactionOverrides(baseOptions, compactData); - - expect(result.model).toBe(KNOWN_MODELS.HAIKU.id); - }); - - it("preserves workspace thinking level for all models", () => { - // Test Anthropic model - const anthropicData: CompactionRequestData = { - model: KNOWN_MODELS.HAIKU.id, - }; - const anthropicResult = applyCompactionOverrides(baseOptions, anthropicData); - expect(anthropicResult.thinkingLevel).toBe("medium"); - - // Test OpenAI model - const openaiData: CompactionRequestData = { - model: "openai:gpt-5-pro", - }; - const openaiResult = applyCompactionOverrides(baseOptions, openaiData); - expect(openaiResult.thinkingLevel).toBe("medium"); - }); - - it("applies maxOutputTokens override", () => { - const compactData: CompactionRequestData = { - maxOutputTokens: 8000, - }; - const result = applyCompactionOverrides(baseOptions, compactData); - - expect(result.maxOutputTokens).toBe(8000); - }); - - it("sets compact mode and disables all tools", () => { - const compactData: CompactionRequestData = {}; - const result = applyCompactionOverrides(baseOptions, compactData); - - expect(result.mode).toBe("compact"); - expect(result.toolPolicy).toEqual([]); - }); - - it("disables all tools even when base options has tool policy", () => { - const baseWithTools: SendMessageOptions = { - ...baseOptions, - toolPolicy: [{ regex_match: "bash", action: "enable" }], - }; - const compactData: CompactionRequestData = {}; - const result = applyCompactionOverrides(baseWithTools, compactData); - - expect(result.mode).toBe("compact"); - expect(result.toolPolicy).toEqual([]); // Tools always disabled for compaction - }); - - it("applies all overrides together", () => { - const compactData: CompactionRequestData = { - model: KNOWN_MODELS.GPT.id, - maxOutputTokens: 5000, - }; - const result = applyCompactionOverrides(baseOptions, compactData); - - expect(result.model).toBe(KNOWN_MODELS.GPT.id); - expect(result.maxOutputTokens).toBe(5000); - expect(result.mode).toBe("compact"); - expect(result.thinkingLevel).toBe("medium"); // Non-Anthropic preserves original - }); -}); diff --git a/src/browser/utils/messages/compactionOptions.ts b/src/browser/utils/messages/compactionOptions.ts deleted file mode 100644 index eda71e44f..000000000 --- a/src/browser/utils/messages/compactionOptions.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Compaction options transformation - * - * Single source of truth for converting compaction metadata into SendMessageOptions. - * Used by both ChatInput (initial send) and useResumeManager (resume after interruption). - */ - -import type { SendMessageOptions } from "@/common/types/ipc"; -import type { CompactionRequestData } from "@/common/types/message"; - -/** - * Apply compaction-specific option overrides to base options. - * - * This function is the single source of truth for how compaction metadata - * transforms workspace defaults. Both initial sends and stream resumption - * use this function to ensure consistent behavior. - * - * @param baseOptions - Workspace default options (from localStorage or useSendMessageOptions) - * @param compactData - Compaction request metadata from /compact command - * @returns Final SendMessageOptions with compaction overrides applied - */ -export function applyCompactionOverrides( - baseOptions: SendMessageOptions, - compactData: CompactionRequestData -): SendMessageOptions { - // Use custom model if specified, otherwise use workspace default - const compactionModel = compactData.model ?? baseOptions.model; - - return { - ...baseOptions, - model: compactionModel, - // Keep workspace default thinking level - all models support thinking now that tools are disabled - thinkingLevel: baseOptions.thinkingLevel, - maxOutputTokens: compactData.maxOutputTokens, - mode: "compact" as const, - toolPolicy: [], // Disable all tools during compaction - }; -} diff --git a/src/common/utils/compaction.test.ts b/src/common/utils/compaction.test.ts new file mode 100644 index 000000000..df264a076 --- /dev/null +++ b/src/common/utils/compaction.test.ts @@ -0,0 +1,228 @@ +/** + * Tests for compaction utilities + */ + +import { createCompactionRequest, applyCompactionOverrides } from "./compaction"; +import type { SendMessageOptions } from "@/common/types/ipc"; +import type { CompactionRequestData } from "@/common/types/message"; +import { KNOWN_MODELS } from "@/common/constants/knownModels"; + +describe("applyCompactionOverrides", () => { + const baseOptions: SendMessageOptions = { + model: KNOWN_MODELS.SONNET.id, + thinkingLevel: "medium", + toolPolicy: [], + mode: "exec", + }; + + it("uses workspace model when no override specified", () => { + const compactData: CompactionRequestData = {}; + const result = applyCompactionOverrides(baseOptions, compactData); + + expect(result.model).toBe(KNOWN_MODELS.SONNET.id); + expect(result.mode).toBe("compact"); + }); + + it("applies custom model override", () => { + const compactData: CompactionRequestData = { + model: KNOWN_MODELS.HAIKU.id, + }; + const result = applyCompactionOverrides(baseOptions, compactData); + + expect(result.model).toBe(KNOWN_MODELS.HAIKU.id); + }); + + it("preserves workspace thinking level for all models", () => { + // Test Anthropic model + const anthropicData: CompactionRequestData = { + model: KNOWN_MODELS.HAIKU.id, + }; + const anthropicResult = applyCompactionOverrides(baseOptions, anthropicData); + expect(anthropicResult.thinkingLevel).toBe("medium"); + + // Test OpenAI model + const openaiData: CompactionRequestData = { + model: "openai:gpt-5-pro", + }; + const openaiResult = applyCompactionOverrides(baseOptions, openaiData); + expect(openaiResult.thinkingLevel).toBe("medium"); + }); + + it("applies maxOutputTokens override", () => { + const compactData: CompactionRequestData = { + maxOutputTokens: 8000, + }; + const result = applyCompactionOverrides(baseOptions, compactData); + + expect(result.maxOutputTokens).toBe(8000); + }); + + it("sets compact mode and disables all tools", () => { + const compactData: CompactionRequestData = {}; + const result = applyCompactionOverrides(baseOptions, compactData); + + expect(result.mode).toBe("compact"); + expect(result.toolPolicy).toEqual([]); + }); + + it("disables all tools even when base options has tool policy", () => { + const baseWithTools: SendMessageOptions = { + ...baseOptions, + toolPolicy: [{ regex_match: "bash", action: "enable" }], + }; + const compactData: CompactionRequestData = {}; + const result = applyCompactionOverrides(baseWithTools, compactData); + + expect(result.mode).toBe("compact"); + expect(result.toolPolicy).toEqual([]); // Tools always disabled for compaction + }); + + it("applies all overrides together", () => { + const compactData: CompactionRequestData = { + model: KNOWN_MODELS.GPT.id, + maxOutputTokens: 5000, + }; + const result = applyCompactionOverrides(baseOptions, compactData); + + expect(result.model).toBe(KNOWN_MODELS.GPT.id); + expect(result.maxOutputTokens).toBe(5000); + expect(result.mode).toBe("compact"); + expect(result.thinkingLevel).toBe("medium"); // Non-Anthropic preserves original + }); +}); + +describe("createCompactionRequest", () => { + const baseOptions: SendMessageOptions = { + model: KNOWN_MODELS.SONNET.id, + thinkingLevel: "medium", + toolPolicy: [{ regex_match: "bash", action: "enable" }], + mode: "exec", + maxOutputTokens: 4000, + }; + + it("creates request with proper overrides applied", () => { + const result = createCompactionRequest({ + baseOptions, + rawCommand: "/compact", + }); + + expect(result.messageText).toContain("Summarize this conversation"); + expect(result.sendOptions.mode).toBe("compact"); + expect(result.sendOptions.toolPolicy).toEqual([]); + expect(result.sendOptions.model).toBe(KNOWN_MODELS.SONNET.id); + expect(result.sendOptions.muxMetadata).toBeDefined(); + expect(result.metadata.type).toBe("compaction-request"); + + if (result.metadata.type === "compaction-request") { + expect(result.metadata.parsed).toBeDefined(); + } + }); + + it("includes continue message in request text", () => { + const result = createCompactionRequest({ + baseOptions, + continueMessage: { text: "Fix the bug" }, + rawCommand: "/compact\nFix the bug", + }); + + expect(result.messageText).toContain("Fix the bug"); + + if (result.metadata.type === "compaction-request") { + expect(result.metadata.parsed.continueMessage).toEqual({ text: "Fix the bug" }); + } + }); + + it("includes images in continue message metadata", () => { + const imageParts = [ + { url: "data:image/png;base64,base64data", mediaType: "image/png" }, + ]; + + const result = createCompactionRequest({ + baseOptions, + continueMessage: { text: "Analyze this", imageParts }, + rawCommand: "/compact\nAnalyze this", + }); + + if (result.metadata.type === "compaction-request") { + expect(result.metadata.parsed.continueMessage?.text).toBe("Analyze this"); + expect(result.metadata.parsed.continueMessage?.imageParts).toEqual(imageParts); + } + }); + + it("applies custom maxOutputTokens override", () => { + const customOptions = { ...baseOptions, maxOutputTokens: 8000 }; + + const result = createCompactionRequest({ + baseOptions: customOptions, + rawCommand: "/compact -t 8000", + }); + + expect(result.sendOptions.maxOutputTokens).toBe(8000); + + if (result.metadata.type === "compaction-request") { + expect(result.metadata.parsed.maxOutputTokens).toBe(8000); + } + // Word target should be approximately maxOutputTokens / 1.3 + expect(result.messageText).toContain("6154 words"); // Math.round(8000 / 1.3) + }); + + it("applies custom model override", () => { + const customOptions = { ...baseOptions, model: KNOWN_MODELS.HAIKU.id }; + + const result = createCompactionRequest({ + baseOptions: customOptions, + rawCommand: "/compact -m haiku", + }); + + expect(result.sendOptions.model).toBe(KNOWN_MODELS.HAIKU.id); + + if (result.metadata.type === "compaction-request") { + expect(result.metadata.parsed.model).toBe(KNOWN_MODELS.HAIKU.id); + } + }); + + it("preserves thinking level from base options", () => { + const result = createCompactionRequest({ + baseOptions, + rawCommand: "/compact", + }); + + expect(result.sendOptions.thinkingLevel).toBe("medium"); + }); + + it("stores raw command in metadata", () => { + const rawCommand = "/compact -m haiku -t 5000\nContinue debugging"; + + const result = createCompactionRequest({ + baseOptions, + continueMessage: { text: "Continue debugging" }, + rawCommand, + }); + + if (result.metadata.type === "compaction-request") { + expect(result.metadata.rawCommand).toBe(rawCommand); + } + }); + + it("attaches metadata to sendOptions", () => { + const result = createCompactionRequest({ + baseOptions, + rawCommand: "/compact", + }); + + expect(result.sendOptions.muxMetadata).toBe(result.metadata); + }); + + it("uses default word target when no maxOutputTokens specified", () => { + const optionsWithoutMax = { ...baseOptions }; + delete optionsWithoutMax.maxOutputTokens; + + const result = createCompactionRequest({ + baseOptions: optionsWithoutMax, + rawCommand: "/compact", + }); + + // Default should be approximately 2000 words + expect(result.messageText).toContain("2000 words"); + }); +}); diff --git a/src/common/utils/compaction.ts b/src/common/utils/compaction.ts index 7e88d8e33..f7a1082ee 100644 --- a/src/common/utils/compaction.ts +++ b/src/common/utils/compaction.ts @@ -1,29 +1,78 @@ /** * Shared compaction utilities for both frontend and backend + * + * Provides factory functions to create compaction requests with proper option overrides, + * ensuring manual /compact commands and auto-compaction behave identically. */ +import type { SendMessageOptions, ImagePart } from "@/common/types/ipc"; import type { MuxFrontendMetadata, CompactionRequestData, ContinueMessage } from "@/common/types/message"; -export interface PrepareCompactionMessageOptions { - maxOutputTokens?: number; - model?: string; +// ============================================================================ +// Option overrides +// ============================================================================ + +/** + * Apply compaction-specific option overrides to base options. + * + * This function is the single source of truth for how compaction metadata + * transforms workspace defaults. Both initial sends and stream resumption + * use this function to ensure consistent behavior. + * + * @param baseOptions - Workspace default options (from localStorage or useSendMessageOptions) + * @param compactData - Compaction request metadata from /compact command + * @returns Final SendMessageOptions with compaction overrides applied + */ +export function applyCompactionOverrides( + baseOptions: SendMessageOptions, + compactData: CompactionRequestData +): SendMessageOptions { + // Use custom model if specified, otherwise use workspace default + const compactionModel = compactData.model ?? baseOptions.model; + + return { + ...baseOptions, + model: compactionModel, + // Keep workspace default thinking level - all models support thinking now that tools are disabled + thinkingLevel: baseOptions.thinkingLevel, + maxOutputTokens: compactData.maxOutputTokens, + mode: "compact" as const, + toolPolicy: [], // Disable all tools during compaction + }; +} + +// ============================================================================ +// Compaction request factory +// ============================================================================ + +export interface CreateCompactionRequestOptions { + baseOptions: SendMessageOptions; // User's workspace defaults + continueMessage?: { text: string; imageParts?: ImagePart[] }; rawCommand: string; - continueMessage?: ContinueMessage; } -export interface PrepareCompactionMessageResult { +export interface CreateCompactionRequestResult { messageText: string; - metadata: MuxFrontendMetadata; + metadata: MuxFrontendMetadata; // For display/regeneration + sendOptions: SendMessageOptions; // Ready to send (has muxMetadata attached) } /** - * Prepare compaction message text and metadata - * Used by both frontend (slash commands) and backend (auto-compaction) + * Create a complete compaction request with proper option overrides + * + * Single source of truth for compaction request creation, used by: + * - Frontend executeCompaction: uses sendOptions directly + * - Frontend ChatInput: uses metadata separately for regeneration + * - Backend auto-compaction: uses sendOptions directly + * + * Ensures all paths apply identical overrides (tools disabled, mode: "compact", etc.) */ -export function prepareCompactionMessage( - options: PrepareCompactionMessageOptions -): PrepareCompactionMessageResult { - const targetWords = options.maxOutputTokens ? Math.round(options.maxOutputTokens / 1.3) : 2000; +export function createCompactionRequest( + options: CreateCompactionRequestOptions +): CreateCompactionRequestResult { + const targetWords = options.baseOptions.maxOutputTokens + ? Math.round(options.baseOptions.maxOutputTokens / 1.3) + : 2000; // Build compaction message with optional continue context let messageText = `Summarize this conversation into a compact form for a new Assistant to continue helping the user. Use approximately ${targetWords} words.`; @@ -34,8 +83,8 @@ export function prepareCompactionMessage( // Create compaction metadata const compactData: CompactionRequestData = { - model: options.model, - maxOutputTokens: options.maxOutputTokens, + model: options.baseOptions.model, + maxOutputTokens: options.baseOptions.maxOutputTokens, continueMessage: options.continueMessage, }; @@ -45,5 +94,12 @@ export function prepareCompactionMessage( parsed: compactData, }; - return { messageText, metadata }; + // Apply compaction overrides to get final send options + const sendOptions = applyCompactionOverrides(options.baseOptions, compactData); + + return { + messageText, + metadata, + sendOptions: { ...sendOptions, muxMetadata: metadata }, + }; } diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index e68123121..1c3393ab0 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -25,7 +25,7 @@ import { createRuntime } from "@/node/runtime/runtimeFactory"; import { MessageQueue } from "./messageQueue"; import type { StreamEndEvent, StreamAbortEvent } from "@/common/types/stream"; import { CompactionHandler } from "./compactionHandler"; -import { prepareCompactionMessage } from "@/common/utils/compaction"; +import { createCompactionRequest } from "@/common/utils/compaction"; export interface AgentSessionChatEvent { workspaceId: string; @@ -282,24 +282,14 @@ export class AgentSession { return Err(createUnknownSendMessageError("No model specified for auto-compaction.")); } - // Prepare compaction with continueMessage - const { messageText, metadata } = prepareCompactionMessage({ - model: options.model, - // To match a manual compaction with a continue message + // Create compaction request with proper overrides + const { messageText, sendOptions } = createCompactionRequest({ + baseOptions: options, + continueMessage: { text: trimmedMessage, imageParts }, rawCommand: `/compact\n${trimmedMessage}`, - continueMessage: { text: trimmedMessage, imageParts } }); - const compactionOptions = { - model: options.model, - thinkingLevel: options.thinkingLevel, - toolPolicy: options.toolPolicy, - additionalSystemInstructions: options.additionalSystemInstructions, - mode: options.mode, - muxMetadata: metadata, - }; - - return this.sendMessage(messageText, compactionOptions); + return this.sendMessage(messageText, sendOptions); } if ( From ca0b511f96b2e1b68c638fd8caf89497bfcbe7be Mon Sep 17 00:00:00 2001 From: ethan Date: Wed, 19 Nov 2025 19:48:28 +1100 Subject: [PATCH 6/8] fix: pass parsed token count to compact options --- src/browser/components/ChatInput/index.tsx | 6 +++--- src/browser/utils/chatCommands.ts | 9 ++++++--- src/common/utils/compaction.test.ts | 12 +++++------- src/common/utils/compaction.ts | 18 +++++++++--------- 4 files changed, 23 insertions(+), 22 deletions(-) diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index b87588018..47b154e65 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -705,14 +705,14 @@ export const ChatInput: React.FC = (props) => { if (parsed?.type === "compact") { // Resolve model with sticky preference handling const effectiveModel = resolveCompactionModel(parsed.model) ?? sendMessageOptions.model; - + const { messageText: regeneratedText, metadata, sendOptions, } = createCompactionRequest({ - baseOptions: { - ...sendMessageOptions, + baseOptions: { + ...sendMessageOptions, model: effectiveModel, maxOutputTokens: parsed.maxOutputTokens, }, diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index 80982a573..bb28d31e4 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -7,7 +7,6 @@ */ import type { SendMessageOptions, ImagePart } from "@/common/types/ipc"; -import type { MuxFrontendMetadata } from "@/common/types/message"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import type { RuntimeConfig } from "@/common/types/runtime"; import { RUNTIME_MODE, SSH_RUNTIME_PREFIX } from "@/common/types/runtime"; @@ -17,7 +16,7 @@ import type { ParsedCommand } from "@/browser/utils/slashCommands/types"; import { resolveCompactionModel } from "@/browser/utils/messages/compactionModelPreference"; import { getRuntimeKey } from "@/common/constants/storage"; import { createCompactionRequest } from "@/common/utils/compaction"; -import { ImageAttachment } from "../components/ImageAttachments"; +import type { ImageAttachment } from "../components/ImageAttachments"; // ============================================================================ // Workspace Creation @@ -199,7 +198,11 @@ export async function executeCompaction(options: CompactionOptions): Promise { expect(result.sendOptions.model).toBe(KNOWN_MODELS.SONNET.id); expect(result.sendOptions.muxMetadata).toBeDefined(); expect(result.metadata.type).toBe("compaction-request"); - + if (result.metadata.type === "compaction-request") { expect(result.metadata.parsed).toBeDefined(); } @@ -126,16 +126,14 @@ describe("createCompactionRequest", () => { }); expect(result.messageText).toContain("Fix the bug"); - + if (result.metadata.type === "compaction-request") { expect(result.metadata.parsed.continueMessage).toEqual({ text: "Fix the bug" }); } }); it("includes images in continue message metadata", () => { - const imageParts = [ - { url: "data:image/png;base64,base64data", mediaType: "image/png" }, - ]; + const imageParts = [{ url: "data:image/png;base64,base64data", mediaType: "image/png" }]; const result = createCompactionRequest({ baseOptions, @@ -158,7 +156,7 @@ describe("createCompactionRequest", () => { }); expect(result.sendOptions.maxOutputTokens).toBe(8000); - + if (result.metadata.type === "compaction-request") { expect(result.metadata.parsed.maxOutputTokens).toBe(8000); } @@ -175,7 +173,7 @@ describe("createCompactionRequest", () => { }); expect(result.sendOptions.model).toBe(KNOWN_MODELS.HAIKU.id); - + if (result.metadata.type === "compaction-request") { expect(result.metadata.parsed.model).toBe(KNOWN_MODELS.HAIKU.id); } diff --git a/src/common/utils/compaction.ts b/src/common/utils/compaction.ts index f7a1082ee..d170ec7f7 100644 --- a/src/common/utils/compaction.ts +++ b/src/common/utils/compaction.ts @@ -1,12 +1,12 @@ /** * Shared compaction utilities for both frontend and backend - * + * * Provides factory functions to create compaction requests with proper option overrides, * ensuring manual /compact commands and auto-compaction behave identically. */ import type { SendMessageOptions, ImagePart } from "@/common/types/ipc"; -import type { MuxFrontendMetadata, CompactionRequestData, ContinueMessage } from "@/common/types/message"; +import type { MuxFrontendMetadata, CompactionRequestData } from "@/common/types/message"; // ============================================================================ // Option overrides @@ -46,32 +46,32 @@ export function applyCompactionOverrides( // ============================================================================ export interface CreateCompactionRequestOptions { - baseOptions: SendMessageOptions; // User's workspace defaults + baseOptions: SendMessageOptions; // User's workspace defaults continueMessage?: { text: string; imageParts?: ImagePart[] }; rawCommand: string; } export interface CreateCompactionRequestResult { messageText: string; - metadata: MuxFrontendMetadata; // For display/regeneration - sendOptions: SendMessageOptions; // Ready to send (has muxMetadata attached) + metadata: MuxFrontendMetadata; // For display/regeneration + sendOptions: SendMessageOptions; // Ready to send (has muxMetadata attached) } /** * Create a complete compaction request with proper option overrides - * + * * Single source of truth for compaction request creation, used by: * - Frontend executeCompaction: uses sendOptions directly * - Frontend ChatInput: uses metadata separately for regeneration * - Backend auto-compaction: uses sendOptions directly - * + * * Ensures all paths apply identical overrides (tools disabled, mode: "compact", etc.) */ export function createCompactionRequest( options: CreateCompactionRequestOptions ): CreateCompactionRequestResult { - const targetWords = options.baseOptions.maxOutputTokens - ? Math.round(options.baseOptions.maxOutputTokens / 1.3) + const targetWords = options.baseOptions.maxOutputTokens + ? Math.round(options.baseOptions.maxOutputTokens / 1.3) : 2000; // Build compaction message with optional continue context From 0cdead8f568e9c5941d59e23dbb0163a6de06c65 Mon Sep 17 00:00:00 2001 From: ethan Date: Wed, 19 Nov 2025 21:06:26 +1100 Subject: [PATCH 7/8] =?UTF-8?q?=F0=9F=A4=96=20feat:=20check=20auto-compact?= =?UTF-8?q?ion=20after=20history=20truncate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add checkAndUpdateAutoCompactionFlag() to AgentSession public API - Call check after truncating history in IPC handler - Fix: clear willCompactNext flag when threshold not met - Fix: remove premature flag clear before auto-compaction - Fix: use /compact without continue message for auto-compaction - Add TODO for configurable threshold --- src/node/services/agentSession.ts | 13 ++++++++----- src/node/services/compactionHandler.ts | 2 ++ src/node/services/ipcMain.ts | 4 ++++ 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 1c3393ab0..1461f9fd8 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -276,8 +276,6 @@ export class AgentSession { this.compactionHandler.getWillCompactNext() && options?.muxMetadata?.type !== "compaction-request" ) { - this.compactionHandler.clearWillCompactNext(); - if (!options?.model) { return Err(createUnknownSendMessageError("No model specified for auto-compaction.")); } @@ -286,7 +284,7 @@ export class AgentSession { const { messageText, sendOptions } = createCompactionRequest({ baseOptions: options, continueMessage: { text: trimmedMessage, imageParts }, - rawCommand: `/compact\n${trimmedMessage}`, + rawCommand: "/compact", }); return this.sendMessage(messageText, sendOptions); @@ -402,6 +400,11 @@ export class AgentSession { return Ok(undefined); } + async checkAndUpdateAutoCompactionFlag(): Promise { + this.assertNotDisposed("checkAndUpdateAutoCompactionFlag"); + return this.compactionHandler.checkAndUpdateAutoCompactionFlag(); + } + private async streamWithHistory( modelString: string, options?: SendMessageOptions @@ -473,7 +476,7 @@ export class AgentSession { const event = payload as StreamEndEvent; const handled = await this.compactionHandler.handleCompletion(event); if (!handled) { - const shouldCompact = await this.compactionHandler.checkAndUpdateAutoCompactionFlag(); + const shouldCompact = await this.checkAndUpdateAutoCompactionFlag(); if (shouldCompact) { event.willCompactOnNextMessage = true; } @@ -487,7 +490,7 @@ export class AgentSession { const event = payload as StreamAbortEvent; const handled = await this.compactionHandler.handleAbort(event); if (!handled) { - const shouldCompact = await this.compactionHandler.checkAndUpdateAutoCompactionFlag(); + const shouldCompact = await this.checkAndUpdateAutoCompactionFlag(); if (shouldCompact) { event.willCompactOnNextMessage = true; } diff --git a/src/node/services/compactionHandler.ts b/src/node/services/compactionHandler.ts index 09fcc7084..24e01383f 100644 --- a/src/node/services/compactionHandler.ts +++ b/src/node/services/compactionHandler.ts @@ -270,6 +270,7 @@ export class CompactionHandler { } // Trigger if we're at or above 70% of the limit + // TODO: make configurable const threshold = maxInputTokens * 0.7; return totalTokens >= threshold; } @@ -284,6 +285,7 @@ export class CompactionHandler { this.willCompactNext = true; return true; } + this.willCompactNext = false; return false; } diff --git a/src/node/services/ipcMain.ts b/src/node/services/ipcMain.ts index 0284674c4..641eeedcf 100644 --- a/src/node/services/ipcMain.ts +++ b/src/node/services/ipcMain.ts @@ -1046,6 +1046,10 @@ export class IpcMain { this.mainWindow.webContents.send(getChatChannel(workspaceId), deleteMessage); } + // Check if auto-compaction should trigger after truncate + const session = this.getOrCreateSession(workspaceId); + await session.checkAndUpdateAutoCompactionFlag(); + return { success: true, data: undefined }; } ); From 2473a8dcf4d374dc4ba3e74750b35cc8b5fd86a7 Mon Sep 17 00:00:00 2001 From: ethan Date: Wed, 19 Nov 2025 21:21:44 +1100 Subject: [PATCH 8/8] =?UTF-8?q?=F0=9F=A4=96=20fix:=20exclude=20images=20fr?= =?UTF-8?q?om=20compaction=20request=20message?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Images should only be uploaded as part of the continue message after compaction completes, not in the compaction request itself. --- src/node/services/agentSession.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 1461f9fd8..0e7a52f54 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -280,9 +280,13 @@ export class AgentSession { return Err(createUnknownSendMessageError("No model specified for auto-compaction.")); } + // Strip imageParts from compaction request, only upload + // images as part of the continue message, post-compaction + const { imageParts: _, ...baseOptionsWithoutImages } = options; + // Create compaction request with proper overrides const { messageText, sendOptions } = createCompactionRequest({ - baseOptions: options, + baseOptions: baseOptionsWithoutImages, continueMessage: { text: trimmedMessage, imageParts }, rawCommand: "/compact", });