From 1e08ebe82449e4ea3c74290c79783e8281c5322d Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 17 Jan 2026 20:07:16 -0600 Subject: [PATCH 01/11] =?UTF-8?q?=F0=9F=A4=96=20fix:=20add=20compact=20ret?= =?UTF-8?q?ry=20action=20for=20stream=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Messages/ChatBarrier/RetryBarrier.tsx | 284 ++--------------- .../Messages/MessageListContext.tsx | 31 ++ .../Messages/StreamErrorMessage.tsx | 27 +- src/browser/hooks/useCompactAndRetry.ts | 291 ++++++++++++++++++ src/common/constants/events.ts | 9 + 5 files changed, 375 insertions(+), 267 deletions(-) create mode 100644 src/browser/components/Messages/MessageListContext.tsx create mode 100644 src/browser/hooks/useCompactAndRetry.ts diff --git a/src/browser/components/Messages/ChatBarrier/RetryBarrier.tsx b/src/browser/components/Messages/ChatBarrier/RetryBarrier.tsx index 335852690b..9ede327554 100644 --- a/src/browser/components/Messages/ChatBarrier/RetryBarrier.tsx +++ b/src/browser/components/Messages/ChatBarrier/RetryBarrier.tsx @@ -1,19 +1,8 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; -import { useAPI } from "@/browser/contexts/API"; -import { buildSendMessageOptions } from "@/browser/hooks/useSendMessageOptions"; +import React, { useEffect, useMemo, useState } from "react"; +import { useCompactAndRetry } from "@/browser/hooks/useCompactAndRetry"; import { usePersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; import type { RetryState } from "@/browser/hooks/useResumeManager"; import { useWorkspaceState } from "@/browser/stores/WorkspaceStore"; -import { - getExplicitCompactionSuggestion, - getHigherContextCompactionSuggestion, - type CompactionSuggestion, -} from "@/browser/utils/compaction/suggestion"; -import { - buildCompactionEditText, - formatCompactionCommandLine, -} from "@/browser/utils/compaction/format"; -import { executeCompaction } from "@/browser/utils/chatCommands"; import { isEligibleForAutoRetry, isNonRetryableSendError, @@ -21,15 +10,8 @@ import { import { calculateBackoffDelay, createManualRetryState } from "@/browser/utils/messages/retryState"; import { KEYBINDS, formatKeybind } from "@/browser/utils/ui/keybinds"; import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; -import { - getAutoRetryKey, - getRetryStateKey, - PREFERRED_COMPACTION_MODEL_KEY, - VIM_ENABLED_KEY, -} from "@/common/constants/storage"; +import { getAutoRetryKey, getRetryStateKey, VIM_ENABLED_KEY } from "@/common/constants/storage"; import { cn } from "@/common/lib/utils"; -import type { ImagePart, ProvidersConfigMap } from "@/common/orpc/types"; -import { buildContinueMessage, type DisplayedMessage } from "@/common/types/message"; import { formatSendMessageError } from "@/common/utils/errors/formatSendError"; import { formatTokens } from "@/common/utils/tokens/tokenMeterUtils"; @@ -42,18 +24,6 @@ function formatContextTokens(tokens: number): string { return formatTokens(tokens).replace(/\.0([kM])$/, "$1"); } -function findTriggerUserMessage( - messages: DisplayedMessage[] -): Extract | null { - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i]; - if (msg.type === "user") { - return msg; - } - } - - return null; -} const defaultRetryState: RetryState = { attempt: 0, retryStartTime: Date.now(), @@ -63,82 +33,14 @@ export const RetryBarrier: React.FC = ({ workspaceId, classNa // Get workspace state for computing effective autoRetry const workspaceState = useWorkspaceState(workspaceId); - const { api } = useAPI(); - const [isRetryingWithCompaction, setIsRetryingWithCompaction] = useState(false); - const isMountedRef = useRef(true); - useEffect(() => { - return () => { - isMountedRef.current = false; - }; - }, []); - - const [providersConfig, setProvidersConfig] = useState(null); - - const lastMessage = workspaceState - ? workspaceState.messages[workspaceState.messages.length - 1] - : undefined; - - const isContextExceeded = - lastMessage?.type === "stream-error" && lastMessage.errorType === "context_exceeded"; - - // Check if we're in a compaction recovery flow: the last user message was a compaction request - // that failed. This persists the compaction UI even if the retry fails with a different error. - const triggerUserMessage = useMemo(() => { - if (!workspaceState) return null; - return findTriggerUserMessage(workspaceState.messages); - }, [workspaceState]); - - const isCompactionRecoveryFlow = - lastMessage?.type === "stream-error" && !!triggerUserMessage?.compactionRequest; - - // Show compaction UI if either: original context_exceeded OR we're retrying a failed compaction - const showCompactionUI = isContextExceeded || isCompactionRecoveryFlow; - - // This is a rare error state; we only need a snapshot of provider config to make a - // best-effort suggestion (no subscriptions / real-time updates required). - useEffect(() => { - if (!api) return; - if (!showCompactionUI) return; - if (providersConfig) return; - - let active = true; - void (async () => { - try { - const cfg = await api.providers.getConfig(); - if (active) { - setProvidersConfig(cfg); - } - } catch { - // Ignore failures fetching config (we just won't show a suggestion). - } - })(); - - return () => { - active = false; - }; - }, [api, showCompactionUI, providersConfig]); - - // For compaction recovery, use the model from the original compaction request or fall back to workspace model - const compactionTargetModel = useMemo(() => { - if (!showCompactionUI) return null; - // If retrying a failed compaction, use the model from that request - if (triggerUserMessage?.compactionRequest?.parsed.model) { - return triggerUserMessage.compactionRequest.parsed.model; - } - // Otherwise use the model from the error or workspace - if (lastMessage?.type === "stream-error") { - return lastMessage.model ?? workspaceState?.currentModel ?? null; - } - return workspaceState?.currentModel ?? null; - }, [showCompactionUI, triggerUserMessage, lastMessage, workspaceState?.currentModel]); - - // Read preferences from localStorage - - const [preferredCompactionModel] = usePersistedState( - PREFERRED_COMPACTION_MODEL_KEY, - "", // Default to empty (use workspace model) - { listener: true } - ); + const { + showCompactionUI, + compactionSuggestion, + hasCompactionRequest, + hasTriggerUserMessage, + isRetryingWithCompaction, + retryWithCompaction, + } = useCompactAndRetry({ workspaceId }); const [autoRetry, setAutoRetry] = usePersistedState( getAutoRetryKey(workspaceId), true, // Default to true @@ -203,160 +105,6 @@ export const RetryBarrier: React.FC = ({ workspaceId, classNa return () => clearInterval(interval); }, [autoRetry, attempt, retryStartTime]); - const compactionSuggestion = useMemo(() => { - // Opportunistic: only attempt suggestions when we can confidently identify the model. - if (!showCompactionUI || !compactionTargetModel) { - return null; - } - - // If we're retrying a failed compaction request, prefer a larger-context model to recover. - if (isCompactionRecoveryFlow) { - return getHigherContextCompactionSuggestion({ - currentModel: compactionTargetModel, - providersConfig, - }); - } - - const preferred = preferredCompactionModel.trim(); - if (preferred.length > 0) { - const explicit = getExplicitCompactionSuggestion({ - modelId: preferred, - providersConfig, - }); - if (explicit) { - return explicit; - } - } - - return getHigherContextCompactionSuggestion({ - currentModel: compactionTargetModel, - providersConfig, - }); - }, [ - compactionTargetModel, - showCompactionUI, - isCompactionRecoveryFlow, - providersConfig, - preferredCompactionModel, - ]); - - async function handleRetryWithCompaction(): Promise { - const insertIntoChatInput = (text: string, imageParts?: ImagePart[]): void => { - window.dispatchEvent( - createCustomEvent(CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT, { - text, - mode: "replace", - imageParts, - }) - ); - }; - - if (!compactionSuggestion) { - insertIntoChatInput("/compact\n"); - return; - } - - const suggestedCommandLine = formatCompactionCommandLine({ - model: compactionSuggestion.modelArg, - }); - - if (!api) { - insertIntoChatInput(suggestedCommandLine + "\n"); - return; - } - - if (isMountedRef.current) { - setIsRetryingWithCompaction(true); - } - try { - // Read fresh values at click-time (workspace might have switched models). - const sendMessageOptions = buildSendMessageOptions(workspaceId); - - // Best-effort: fall back to the nearest user message if we can't find the exact one. - const source = triggerUserMessage; - - if (!source) { - insertIntoChatInput(suggestedCommandLine + "\n"); - return; - } - - if (source.compactionRequest) { - const maxOutputTokens = source.compactionRequest.parsed.maxOutputTokens; - const continueMessage = source.compactionRequest.parsed.continueMessage; - - const result = await executeCompaction({ - api, - workspaceId, - sendMessageOptions, - model: compactionSuggestion.modelId, - maxOutputTokens, - continueMessage, - }); - - if (!result.success) { - console.error("Failed to retry compaction:", result.error); - - const rawCommand = formatCompactionCommandLine({ - model: compactionSuggestion.modelArg, - maxOutputTokens, - }); - - const fallbackText = buildCompactionEditText({ - rawCommand, - parsed: { - model: compactionSuggestion.modelArg, - maxOutputTokens, - continueMessage, - }, - }); - - const shouldAppendNewline = - !continueMessage?.text || continueMessage.text.trim().length === 0; - - insertIntoChatInput( - fallbackText + (shouldAppendNewline ? "\n" : ""), - continueMessage?.imageParts - ); - } - - return; - } - - const continueMessage = buildContinueMessage({ - text: source.content, - imageParts: source.imageParts, - reviews: source.reviews, - model: sendMessageOptions.model, - agentId: sendMessageOptions.agentId ?? "exec", - }); - - if (!continueMessage) { - insertIntoChatInput(suggestedCommandLine + "\n"); - return; - } - - const result = await executeCompaction({ - api, - workspaceId, - sendMessageOptions, - model: compactionSuggestion.modelId, - continueMessage, - }); - - if (!result.success) { - console.error("Failed to start compaction:", result.error); - insertIntoChatInput(suggestedCommandLine + "\n" + source.content, source.imageParts); - } - } catch (error) { - console.error("Failed to retry with compaction", error); - insertIntoChatInput(suggestedCommandLine + "\n"); - } finally { - if (isMountedRef.current) { - setIsRetryingWithCompaction(false); - } - } - } - // Manual retry handler (user-initiated, immediate) // Emits event to useResumeManager instead of calling resumeStream directly // This keeps all retry logic centralized in one place @@ -462,15 +210,19 @@ export const RetryBarrier: React.FC = ({ workspaceId, classNa ); } else { - const onClick = showCompactionUI ? () => void handleRetryWithCompaction() : handleManualRetry; + const onClick = showCompactionUI + ? () => { + retryWithCompaction().catch(() => undefined); + } + : handleManualRetry; let label = "Retry"; if (showCompactionUI) { if (isRetryingWithCompaction) { label = "Starting..."; - } else if (!compactionSuggestion || !triggerUserMessage) { + } else if (!compactionSuggestion || !hasTriggerUserMessage) { label = "Insert /compact"; - } else if (triggerUserMessage.compactionRequest) { + } else if (hasCompactionRequest) { label = "Retry compaction"; } else { label = "Compact & retry"; diff --git a/src/browser/components/Messages/MessageListContext.tsx b/src/browser/components/Messages/MessageListContext.tsx new file mode 100644 index 0000000000..fad3aa3bb7 --- /dev/null +++ b/src/browser/components/Messages/MessageListContext.tsx @@ -0,0 +1,31 @@ +import React, { createContext, useContext } from "react"; + +interface MessageListContextValue { + workspaceId: string; + latestMessageId: string | null; +} + +const MessageListContext = createContext(null); + +interface MessageListProviderProps { + value: MessageListContextValue; + children: React.ReactNode; +} + +export const MessageListProvider: React.FC = (props) => { + return ( + {props.children} + ); +}; + +export function useOptionalMessageListContext(): MessageListContextValue | null { + return useContext(MessageListContext); +} + +export function useMessageListContext(): MessageListContextValue { + const context = useContext(MessageListContext); + if (!context) { + throw new Error("useMessageListContext must be used within MessageListProvider"); + } + return context; +} diff --git a/src/browser/components/Messages/StreamErrorMessage.tsx b/src/browser/components/Messages/StreamErrorMessage.tsx index 1709a13eae..2b5f2c0ad2 100644 --- a/src/browser/components/Messages/StreamErrorMessage.tsx +++ b/src/browser/components/Messages/StreamErrorMessage.tsx @@ -4,6 +4,7 @@ import { Button } from "@/browser/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/browser/components/ui/tooltip"; import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; import { cn } from "@/common/lib/utils"; +import { useOptionalMessageListContext } from "./MessageListContext"; import type { DisplayedMessage } from "@/common/types/message"; interface StreamErrorMessageProps { @@ -11,8 +12,31 @@ interface StreamErrorMessageProps { className?: string; } -// Note: RetryBarrier handles retry actions. This component only displays the error. +// RetryBarrier handles auto-retry; this component exposes a compact-and-retry shortcut. export const StreamErrorMessage: React.FC = ({ message, className }) => { + const messageListContext = useOptionalMessageListContext(); + const latestMessageId = messageListContext?.latestMessageId ?? null; + const workspaceId = messageListContext?.workspaceId ?? null; + const isLatestMessage = latestMessageId === message.id; + + const showCompactRetry = + message.errorType === "context_exceeded" && !!workspaceId && isLatestMessage; + + const compactRetryAction = showCompactRetry ? ( + + ) : null; const debugAction = ( @@ -68,6 +92,7 @@ export const StreamErrorMessage: React.FC = ({ message, {message.errorType}
+ {compactRetryAction} {showCount && ( ×{message.errorCount} diff --git a/src/browser/hooks/useCompactAndRetry.ts b/src/browser/hooks/useCompactAndRetry.ts new file mode 100644 index 0000000000..8ad8de319a --- /dev/null +++ b/src/browser/hooks/useCompactAndRetry.ts @@ -0,0 +1,291 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useAPI } from "@/browser/contexts/API"; +import { buildSendMessageOptions } from "@/browser/hooks/useSendMessageOptions"; +import { usePersistedState } from "@/browser/hooks/usePersistedState"; +import { useWorkspaceState } from "@/browser/stores/WorkspaceStore"; +import { + buildCompactionEditText, + formatCompactionCommandLine, +} from "@/browser/utils/compaction/format"; +import { + getExplicitCompactionSuggestion, + getHigherContextCompactionSuggestion, + type CompactionSuggestion, +} from "@/browser/utils/compaction/suggestion"; +import { executeCompaction } from "@/browser/utils/chatCommands"; +import { CUSTOM_EVENTS, createCustomEvent, type CustomEventType } from "@/common/constants/events"; +import { PREFERRED_COMPACTION_MODEL_KEY } from "@/common/constants/storage"; +import type { ImagePart, ProvidersConfigMap } from "@/common/orpc/types"; +import { buildContinueMessage, type DisplayedMessage } from "@/common/types/message"; + +interface CompactAndRetryState { + showCompactionUI: boolean; + compactionSuggestion: CompactionSuggestion | null; + isRetryingWithCompaction: boolean; + hasTriggerUserMessage: boolean; + hasCompactionRequest: boolean; + retryWithCompaction: () => Promise; +} + +function findTriggerUserMessage( + messages: DisplayedMessage[] +): Extract | null { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.type === "user") { + return msg; + } + } + + return null; +} + +export function useCompactAndRetry(props: { workspaceId: string }): CompactAndRetryState { + const workspaceState = useWorkspaceState(props.workspaceId); + const { api } = useAPI(); + const [providersConfig, setProvidersConfig] = useState(null); + const [isRetryingWithCompaction, setIsRetryingWithCompaction] = useState(false); + const isMountedRef = useRef(true); + + useEffect(() => { + return () => { + isMountedRef.current = false; + }; + }, []); + + const lastMessage = workspaceState + ? workspaceState.messages[workspaceState.messages.length - 1] + : undefined; + + const triggerUserMessage = useMemo(() => { + if (!workspaceState) return null; + return findTriggerUserMessage(workspaceState.messages); + }, [workspaceState]); + + const isCompactionRecoveryFlow = + lastMessage?.type === "stream-error" && !!triggerUserMessage?.compactionRequest; + + const isContextExceeded = + lastMessage?.type === "stream-error" && lastMessage.errorType === "context_exceeded"; + + const showCompactionUI = isContextExceeded || isCompactionRecoveryFlow; + + const [preferredCompactionModel] = usePersistedState(PREFERRED_COMPACTION_MODEL_KEY, "", { + listener: true, + }); + + useEffect(() => { + if (!api) return; + if (!showCompactionUI) return; + if (providersConfig) return; + + let active = true; + const fetchProvidersConfig = async () => { + try { + const cfg = await api.providers.getConfig(); + if (active) { + setProvidersConfig(cfg); + } + } catch { + // Ignore failures fetching config (we just won't show a suggestion). + } + }; + + fetchProvidersConfig().catch(() => undefined); + + return () => { + active = false; + }; + }, [api, showCompactionUI, providersConfig]); + + const compactionTargetModel = useMemo(() => { + if (!showCompactionUI) return null; + if (triggerUserMessage?.compactionRequest?.parsed.model) { + return triggerUserMessage.compactionRequest.parsed.model; + } + if (lastMessage?.type === "stream-error") { + return lastMessage.model ?? workspaceState?.currentModel ?? null; + } + return workspaceState?.currentModel ?? null; + }, [showCompactionUI, triggerUserMessage, lastMessage, workspaceState?.currentModel]); + + const compactionSuggestion = useMemo(() => { + if (!showCompactionUI || !compactionTargetModel) { + return null; + } + + if (isCompactionRecoveryFlow) { + return getHigherContextCompactionSuggestion({ + currentModel: compactionTargetModel, + providersConfig, + }); + } + + const preferred = preferredCompactionModel.trim(); + if (preferred.length > 0) { + const explicit = getExplicitCompactionSuggestion({ + modelId: preferred, + providersConfig, + }); + if (explicit) { + return explicit; + } + } + + return getHigherContextCompactionSuggestion({ + currentModel: compactionTargetModel, + providersConfig, + }); + }, [ + compactionTargetModel, + showCompactionUI, + isCompactionRecoveryFlow, + providersConfig, + preferredCompactionModel, + ]); + + const retryWithCompaction = useCallback(async (): Promise => { + const insertIntoChatInput = (text: string, imageParts?: ImagePart[]): void => { + window.dispatchEvent( + createCustomEvent(CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT, { + text, + mode: "replace", + imageParts, + }) + ); + }; + + if (!compactionSuggestion) { + insertIntoChatInput("/compact\n"); + return; + } + + const suggestedCommandLine = formatCompactionCommandLine({ + model: compactionSuggestion.modelArg, + }); + + if (!api) { + insertIntoChatInput(suggestedCommandLine + "\n"); + return; + } + + if (isMountedRef.current) { + setIsRetryingWithCompaction(true); + } + try { + const sendMessageOptions = buildSendMessageOptions(props.workspaceId); + const source = triggerUserMessage; + + if (!source) { + insertIntoChatInput(suggestedCommandLine + "\n"); + return; + } + + if (source.compactionRequest) { + const maxOutputTokens = source.compactionRequest.parsed.maxOutputTokens; + const continueMessage = source.compactionRequest.parsed.continueMessage; + + const result = await executeCompaction({ + api, + workspaceId: props.workspaceId, + sendMessageOptions, + model: compactionSuggestion.modelId, + maxOutputTokens, + continueMessage, + }); + + if (!result.success) { + console.error("Failed to retry compaction:", result.error); + + const rawCommand = formatCompactionCommandLine({ + model: compactionSuggestion.modelArg, + maxOutputTokens, + }); + + const fallbackText = buildCompactionEditText({ + rawCommand, + parsed: { + model: compactionSuggestion.modelArg, + maxOutputTokens, + continueMessage, + }, + }); + + const shouldAppendNewline = + !continueMessage?.text || continueMessage.text.trim().length === 0; + + insertIntoChatInput( + fallbackText + (shouldAppendNewline ? "\n" : ""), + continueMessage?.imageParts + ); + } + + return; + } + + const continueMessage = buildContinueMessage({ + text: source.content, + imageParts: source.imageParts, + reviews: source.reviews, + model: sendMessageOptions.model, + agentId: sendMessageOptions.agentId ?? "exec", + }); + + if (!continueMessage) { + insertIntoChatInput(suggestedCommandLine + "\n"); + return; + } + + const result = await executeCompaction({ + api, + workspaceId: props.workspaceId, + sendMessageOptions, + model: compactionSuggestion.modelId, + continueMessage, + }); + + if (!result.success) { + console.error("Failed to start compaction:", result.error); + insertIntoChatInput(suggestedCommandLine + "\n" + source.content, source.imageParts); + } + } catch (error) { + console.error("Failed to retry with compaction", error); + insertIntoChatInput(suggestedCommandLine + "\n"); + } finally { + if (isMountedRef.current) { + setIsRetryingWithCompaction(false); + } + } + }, [api, compactionSuggestion, props.workspaceId, triggerUserMessage]); + + useEffect(() => { + if (!showCompactionUI) return; + + const handleCompactRetryRequested = (event: Event) => { + const customEvent = event as CustomEventType< + typeof CUSTOM_EVENTS.COMPACT_AND_RETRY_REQUESTED + >; + if (customEvent.detail.workspaceId !== props.workspaceId) return; + if (isRetryingWithCompaction) return; + retryWithCompaction().catch(() => undefined); + }; + + window.addEventListener(CUSTOM_EVENTS.COMPACT_AND_RETRY_REQUESTED, handleCompactRetryRequested); + + return () => { + window.removeEventListener( + CUSTOM_EVENTS.COMPACT_AND_RETRY_REQUESTED, + handleCompactRetryRequested + ); + }; + }, [isRetryingWithCompaction, props.workspaceId, retryWithCompaction, showCompactionUI]); + + return { + showCompactionUI, + compactionSuggestion, + isRetryingWithCompaction, + hasTriggerUserMessage: !!triggerUserMessage, + hasCompactionRequest: !!triggerUserMessage?.compactionRequest, + retryWithCompaction, + }; +} diff --git a/src/common/constants/events.ts b/src/common/constants/events.ts index 1ba4b4520a..8bbe5cb5e1 100644 --- a/src/common/constants/events.ts +++ b/src/common/constants/events.ts @@ -52,6 +52,12 @@ export const CUSTOM_EVENTS = { */ RESUME_CHECK_REQUESTED: "mux:resumeCheckRequested", + /** + * Event to request a compact-and-retry flow for a workspace + * Detail: { workspaceId: string } + */ + COMPACT_AND_RETRY_REQUESTED: "mux:compactAndRetryRequested", + /** * Event to switch to a different workspace after fork * Detail: { workspaceId: string, projectPath: string, projectName: string, workspacePath: string, branch: string } @@ -103,6 +109,9 @@ export interface CustomEventPayloads { workspaceId: string; isManual?: boolean; // true when user explicitly clicks retry (bypasses eligibility checks) }; + [CUSTOM_EVENTS.COMPACT_AND_RETRY_REQUESTED]: { + workspaceId: string; + }; [CUSTOM_EVENTS.WORKSPACE_FORK_SWITCH]: { workspaceId: string; projectPath: string; From 30ac83bb6b95211c50a4d704629d645b0015f4df Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 17 Jan 2026 20:49:51 -0600 Subject: [PATCH 02/11] =?UTF-8?q?=F0=9F=A4=96=20tests:=20allow=20multiple?= =?UTF-8?q?=20compact=20retry=20buttons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...tExceededCompactionSuggestion.integration.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/ui/contextExceededCompactionSuggestion.integration.test.ts b/tests/ui/contextExceededCompactionSuggestion.integration.test.ts index f65c026220..a45fc0bd44 100644 --- a/tests/ui/contextExceededCompactionSuggestion.integration.test.ts +++ b/tests/ui/contextExceededCompactionSuggestion.integration.test.ts @@ -81,8 +81,8 @@ describeIntegration("Context exceeded compaction suggestion (UI)", () => { // And we should render an action button for one-click compaction + retry. await waitFor( () => { - const button = view.queryByRole("button", { name: "Compact & retry" }); - if (!button) { + const buttons = view.queryAllByRole("button", { name: "Compact & retry" }); + if (buttons.length === 0) { throw new Error("Expected Compact & retry button"); } }, @@ -105,7 +105,7 @@ describeIntegration("Context exceeded compaction suggestion (UI)", () => { // Clicking the CTA should actually send a compaction request message. // We assert on the rendered /compact command (from muxMetadata.rawCommand). - const button = view.getByRole("button", { name: "Compact & retry" }); + const [button] = view.getAllByRole("button", { name: "Compact & retry" }); if (view.container.textContent?.includes(expectedCompactionCommand)) { throw new Error("Compaction command should not be present before clicking"); } @@ -176,8 +176,8 @@ describeIntegration("Context exceeded compaction suggestion (UI)", () => { // And we should render an action button for one-click compaction + retry. await waitFor( () => { - const button = view.queryByRole("button", { name: "Compact & retry" }); - if (!button) { + const buttons = view.queryAllByRole("button", { name: "Compact & retry" }); + if (buttons.length === 0) { throw new Error("Expected Compact & retry button"); } }, @@ -201,7 +201,7 @@ describeIntegration("Context exceeded compaction suggestion (UI)", () => { // Clicking the CTA should actually send a compaction request message. // We assert on the rendered /compact command (from muxMetadata.rawCommand). - const button = view.getByRole("button", { name: "Compact & retry" }); + const [button] = view.getAllByRole("button", { name: "Compact & retry" }); if (view.container.textContent?.includes(expectedCompactionCommand)) { throw new Error("Compaction command should not be present before clicking"); } From dc57b433f4fbf496107abb96805f3662a6014600 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 18 Jan 2026 10:33:37 -0600 Subject: [PATCH 03/11] =?UTF-8?q?=F0=9F=A4=96=20fix:=20move=20compact=20re?= =?UTF-8?q?try=20into=20stream=20error=20card?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Messages/ChatBarrier/RetryBarrier.tsx | 87 ++--------- .../Messages/StreamErrorMessage.tsx | 138 +++++++++++++++--- src/browser/hooks/useCompactAndRetry.ts | 24 +-- src/common/constants/events.ts | 9 -- 4 files changed, 126 insertions(+), 132 deletions(-) diff --git a/src/browser/components/Messages/ChatBarrier/RetryBarrier.tsx b/src/browser/components/Messages/ChatBarrier/RetryBarrier.tsx index 9ede327554..2813dd5c4b 100644 --- a/src/browser/components/Messages/ChatBarrier/RetryBarrier.tsx +++ b/src/browser/components/Messages/ChatBarrier/RetryBarrier.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useMemo, useState } from "react"; -import { useCompactAndRetry } from "@/browser/hooks/useCompactAndRetry"; import { usePersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; import type { RetryState } from "@/browser/hooks/useResumeManager"; import { useWorkspaceState } from "@/browser/stores/WorkspaceStore"; @@ -13,36 +12,23 @@ import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; import { getAutoRetryKey, getRetryStateKey, VIM_ENABLED_KEY } from "@/common/constants/storage"; import { cn } from "@/common/lib/utils"; import { formatSendMessageError } from "@/common/utils/errors/formatSendError"; -import { formatTokens } from "@/common/utils/tokens/tokenMeterUtils"; interface RetryBarrierProps { workspaceId: string; className?: string; } -function formatContextTokens(tokens: number): string { - return formatTokens(tokens).replace(/\.0([kM])$/, "$1"); -} - const defaultRetryState: RetryState = { attempt: 0, retryStartTime: Date.now(), }; -export const RetryBarrier: React.FC = ({ workspaceId, className }) => { +export const RetryBarrier: React.FC = (props) => { // Get workspace state for computing effective autoRetry - const workspaceState = useWorkspaceState(workspaceId); - - const { - showCompactionUI, - compactionSuggestion, - hasCompactionRequest, - hasTriggerUserMessage, - isRetryingWithCompaction, - retryWithCompaction, - } = useCompactAndRetry({ workspaceId }); + const workspaceState = useWorkspaceState(props.workspaceId); + const [autoRetry, setAutoRetry] = usePersistedState( - getAutoRetryKey(workspaceId), + getAutoRetryKey(props.workspaceId), true, // Default to true { listener: true } ); @@ -56,7 +42,7 @@ export const RetryBarrier: React.FC = ({ workspaceId, classNa // Use persisted state for retry tracking (survives workspace switches) // Read retry state (managed by useResumeManager) const [retryState] = usePersistedState( - getRetryStateKey(workspaceId), + getRetryStateKey(props.workspaceId), defaultRetryState, { listener: true } ); @@ -113,13 +99,13 @@ export const RetryBarrier: React.FC = ({ workspaceId, classNa // Create manual retry state: immediate retry BUT preserves attempt counter // This prevents infinite retry loops without backoff if the retry fails - updatePersistedState(getRetryStateKey(workspaceId), createManualRetryState(attempt)); + updatePersistedState(getRetryStateKey(props.workspaceId), createManualRetryState(attempt)); // Emit event to useResumeManager - it will handle the actual resume // Pass isManual flag to bypass eligibility checks (user explicitly wants to retry) window.dispatchEvent( createCustomEvent(CUSTOM_EVENTS.RESUME_CHECK_REQUESTED, { - workspaceId, + workspaceId: props.workspaceId, isManual: true, }) ); @@ -141,38 +127,7 @@ export const RetryBarrier: React.FC = ({ workspaceId, classNa : formatted.message; }; - const details = showCompactionUI ? ( -
- Context window exceeded.{" "} - {compactionSuggestion ? ( - compactionSuggestion.kind === "preferred" ? ( - <> - We'll compact with your configured compaction model{" "} - - {compactionSuggestion.displayName} - - {compactionSuggestion.maxInputTokens !== null ? ( - <> ({formatContextTokens(compactionSuggestion.maxInputTokens)} context) - ) : null}{" "} - to unblock you. Your workspace model stays the same. - - ) : ( - <> - We'll compact with{" "} - - {compactionSuggestion.displayName} - - {compactionSuggestion.maxInputTokens !== null ? ( - <> ({formatContextTokens(compactionSuggestion.maxInputTokens)} context) - ) : null}{" "} - to unblock you with a higher-context model. Your workspace model stays the same. - - ) - ) : ( - <>Compact this chat to unblock you. Your workspace model stays the same. - )} -
- ) : lastError ? ( + const details = lastError ? (
Error: {getErrorMessage(lastError)}
@@ -180,7 +135,7 @@ export const RetryBarrier: React.FC = ({ workspaceId, classNa const barrierClassName = cn( "my-5 px-5 py-4 bg-gradient-to-br from-[rgba(255,165,0,0.1)] to-[rgba(255,140,0,0.1)] border-l-4 border-warning rounded flex flex-col gap-3", - className + props.className ); let statusIcon = "⚠️"; @@ -210,32 +165,12 @@ export const RetryBarrier: React.FC = ({ workspaceId, classNa ); } else { - const onClick = showCompactionUI - ? () => { - retryWithCompaction().catch(() => undefined); - } - : handleManualRetry; - - let label = "Retry"; - if (showCompactionUI) { - if (isRetryingWithCompaction) { - label = "Starting..."; - } else if (!compactionSuggestion || !hasTriggerUserMessage) { - label = "Insert /compact"; - } else if (hasCompactionRequest) { - label = "Retry compaction"; - } else { - label = "Compact & retry"; - } - } - actionButton = ( ); } diff --git a/src/browser/components/Messages/StreamErrorMessage.tsx b/src/browser/components/Messages/StreamErrorMessage.tsx index 2b5f2c0ad2..f6c8d7049d 100644 --- a/src/browser/components/Messages/StreamErrorMessage.tsx +++ b/src/browser/components/Messages/StreamErrorMessage.tsx @@ -2,41 +2,33 @@ import React from "react"; import { Bug } from "lucide-react"; import { Button } from "@/browser/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/browser/components/ui/tooltip"; +import { useCompactAndRetry } from "@/browser/hooks/useCompactAndRetry"; import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; import { cn } from "@/common/lib/utils"; -import { useOptionalMessageListContext } from "./MessageListContext"; import type { DisplayedMessage } from "@/common/types/message"; +import { formatTokens } from "@/common/utils/tokens/tokenMeterUtils"; +import { useOptionalMessageListContext } from "./MessageListContext"; interface StreamErrorMessageProps { message: DisplayedMessage & { type: "stream-error" }; className?: string; } -// RetryBarrier handles auto-retry; this component exposes a compact-and-retry shortcut. -export const StreamErrorMessage: React.FC = ({ message, className }) => { - const messageListContext = useOptionalMessageListContext(); - const latestMessageId = messageListContext?.latestMessageId ?? null; - const workspaceId = messageListContext?.workspaceId ?? null; - const isLatestMessage = latestMessageId === message.id; +interface StreamErrorMessageBaseProps extends StreamErrorMessageProps { + compactRetryAction?: React.ReactNode; + compactionDetails?: React.ReactNode; +} - const showCompactRetry = - message.errorType === "context_exceeded" && !!workspaceId && isLatestMessage; +function formatContextTokens(tokens: number): string { + return formatTokens(tokens).replace(/\.0([kM])$/, "$1"); +} + +const StreamErrorMessageBase: React.FC = (props) => { + const message = props.message; + const className = props.className; + const compactRetryAction = props.compactRetryAction; + const compactionDetails = props.compactionDetails; - const compactRetryAction = showCompactRetry ? ( - - ) : null; const debugAction = ( @@ -104,6 +96,104 @@ export const StreamErrorMessage: React.FC = ({ message,
{message.error}
+ {compactionDetails}
); }; + +interface StreamErrorMessageWithRetryProps extends StreamErrorMessageProps { + workspaceId: string; +} + +const StreamErrorMessageWithRetry: React.FC = (props) => { + const compactAndRetry = useCompactAndRetry({ workspaceId: props.workspaceId }); + const showCompactRetry = compactAndRetry.showCompactionUI; + + let compactRetryLabel = "Compact & retry"; + if (showCompactRetry) { + if (compactAndRetry.isRetryingWithCompaction) { + compactRetryLabel = "Starting..."; + } else if (!compactAndRetry.compactionSuggestion || !compactAndRetry.hasTriggerUserMessage) { + compactRetryLabel = "Insert /compact"; + } else if (compactAndRetry.hasCompactionRequest) { + compactRetryLabel = "Retry compaction"; + } + } + + const compactRetryAction = showCompactRetry ? ( + + ) : null; + + const compactionSuggestion = compactAndRetry.compactionSuggestion; + const compactionDetails = showCompactRetry ? ( +
+ Context window exceeded.{" "} + {compactionSuggestion ? ( + compactionSuggestion.kind === "preferred" ? ( + <> + We'll compact with your configured compaction model{" "} + + {compactionSuggestion.displayName} + + {compactionSuggestion.maxInputTokens !== null ? ( + <> ({formatContextTokens(compactionSuggestion.maxInputTokens)} context) + ) : null}{" "} + to unblock you. Your workspace model stays the same. + + ) : ( + <> + We'll compact with{" "} + + {compactionSuggestion.displayName} + + {compactionSuggestion.maxInputTokens !== null ? ( + <> ({formatContextTokens(compactionSuggestion.maxInputTokens)} context) + ) : null}{" "} + to unblock you with a higher-context model. Your workspace model stays the same. + + ) + ) : ( + <>Compact this chat to unblock you. Your workspace model stays the same. + )} +
+ ) : null; + + return ( + + ); +}; + +// RetryBarrier handles auto-retry; compaction retry UI lives here for stream errors. +export const StreamErrorMessage: React.FC = (props) => { + const messageListContext = useOptionalMessageListContext(); + const latestMessageId = messageListContext?.latestMessageId ?? null; + const workspaceId = messageListContext?.workspaceId ?? null; + const isLatestMessage = latestMessageId === props.message.id; + + if (!workspaceId || !isLatestMessage) { + return ; + } + + return ( + + ); +}; diff --git a/src/browser/hooks/useCompactAndRetry.ts b/src/browser/hooks/useCompactAndRetry.ts index 8ad8de319a..de0ad5e09e 100644 --- a/src/browser/hooks/useCompactAndRetry.ts +++ b/src/browser/hooks/useCompactAndRetry.ts @@ -13,7 +13,7 @@ import { type CompactionSuggestion, } from "@/browser/utils/compaction/suggestion"; import { executeCompaction } from "@/browser/utils/chatCommands"; -import { CUSTOM_EVENTS, createCustomEvent, type CustomEventType } from "@/common/constants/events"; +import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; import { PREFERRED_COMPACTION_MODEL_KEY } from "@/common/constants/storage"; import type { ImagePart, ProvidersConfigMap } from "@/common/orpc/types"; import { buildContinueMessage, type DisplayedMessage } from "@/common/types/message"; @@ -258,28 +258,6 @@ export function useCompactAndRetry(props: { workspaceId: string }): CompactAndRe } }, [api, compactionSuggestion, props.workspaceId, triggerUserMessage]); - useEffect(() => { - if (!showCompactionUI) return; - - const handleCompactRetryRequested = (event: Event) => { - const customEvent = event as CustomEventType< - typeof CUSTOM_EVENTS.COMPACT_AND_RETRY_REQUESTED - >; - if (customEvent.detail.workspaceId !== props.workspaceId) return; - if (isRetryingWithCompaction) return; - retryWithCompaction().catch(() => undefined); - }; - - window.addEventListener(CUSTOM_EVENTS.COMPACT_AND_RETRY_REQUESTED, handleCompactRetryRequested); - - return () => { - window.removeEventListener( - CUSTOM_EVENTS.COMPACT_AND_RETRY_REQUESTED, - handleCompactRetryRequested - ); - }; - }, [isRetryingWithCompaction, props.workspaceId, retryWithCompaction, showCompactionUI]); - return { showCompactionUI, compactionSuggestion, diff --git a/src/common/constants/events.ts b/src/common/constants/events.ts index 8bbe5cb5e1..1ba4b4520a 100644 --- a/src/common/constants/events.ts +++ b/src/common/constants/events.ts @@ -52,12 +52,6 @@ export const CUSTOM_EVENTS = { */ RESUME_CHECK_REQUESTED: "mux:resumeCheckRequested", - /** - * Event to request a compact-and-retry flow for a workspace - * Detail: { workspaceId: string } - */ - COMPACT_AND_RETRY_REQUESTED: "mux:compactAndRetryRequested", - /** * Event to switch to a different workspace after fork * Detail: { workspaceId: string, projectPath: string, projectName: string, workspacePath: string, branch: string } @@ -109,9 +103,6 @@ export interface CustomEventPayloads { workspaceId: string; isManual?: boolean; // true when user explicitly clicks retry (bypasses eligibility checks) }; - [CUSTOM_EVENTS.COMPACT_AND_RETRY_REQUESTED]: { - workspaceId: string; - }; [CUSTOM_EVENTS.WORKSPACE_FORK_SWITCH]: { workspaceId: string; projectPath: string; From 8e1b6982d8d20957ba58a92e78c88bc40a14ee34 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 18 Jan 2026 11:03:03 -0600 Subject: [PATCH 04/11] =?UTF-8?q?=F0=9F=A4=96=20fix:=20wire=20message=20li?= =?UTF-8?q?st=20context=20in=20chat=20pane?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/components/ChatPane.tsx | 161 +++++++++++++++------------- 1 file changed, 89 insertions(+), 72 deletions(-) diff --git a/src/browser/components/ChatPane.tsx b/src/browser/components/ChatPane.tsx index 086ac47693..4d6ea44c1f 100644 --- a/src/browser/components/ChatPane.tsx +++ b/src/browser/components/ChatPane.tsx @@ -7,6 +7,7 @@ import React, { useDeferredValue, useMemo, } from "react"; +import { MessageListProvider } from "./Messages/MessageListContext"; import { cn } from "@/common/lib/utils"; import { MessageRenderer } from "./Messages/MessageRenderer"; import { InterruptedBarrier } from "./Messages/ChatBarrier/InterruptedBarrier"; @@ -170,6 +171,15 @@ export const ChatPane: React.FC = (props) => { ? transformedMessages : deferredTransformedMessages; + const latestMessageId = + deferredMessages.length > 0 + ? (deferredMessages[deferredMessages.length - 1]?.id ?? null) + : null; + const messageListContextValue = useMemo( + () => ({ workspaceId, latestMessageId }), + [workspaceId, latestMessageId] + ); + const autoCompactionResult = useMemo( () => checkAutoCompaction(workspaceUsage, pendingModel, use1M, autoCompactionThreshold / 100), [workspaceUsage, pendingModel, use1M, autoCompactionThreshold] @@ -448,6 +458,11 @@ export const ChatPane: React.FC = (props) => { ) : false; + const lastMessage = workspaceState.messages[workspaceState.messages.length - 1]; + const suppressRetryBarrier = + lastMessage?.type === "stream-error" && lastMessage.errorType === "context_exceeded"; + const showRetryBarrierUI = showRetryBarrier && !suppressRetryBarrier; + // Handle keyboard shortcuts (using optional refs that are safe even if not initialized) useAIViewKeybinds({ workspaceId, @@ -557,79 +572,81 @@ export const ChatPane: React.FC = (props) => {

) : ( - <> - {deferredMessages.map((msg, index) => { - // Compute bash_output grouping at render-time - const bashOutputGroup = computeBashOutputGroupInfo(deferredMessages, index); - - // For bash_output groups, use first message ID as expansion key - const groupKey = bashOutputGroup - ? deferredMessages[bashOutputGroup.firstIndex]?.id - : undefined; - const isGroupExpanded = groupKey ? expandedBashGroups.has(groupKey) : false; - - // Skip rendering middle items in a bash_output group (unless expanded) - if (bashOutputGroup?.position === "middle" && !isGroupExpanded) { - return null; - } - - const isAtCutoff = - editCutoffHistoryId !== undefined && - msg.type !== "history-hidden" && - msg.type !== "workspace-init" && - msg.historyId === editCutoffHistoryId; - - return ( - -
- + <> + {deferredMessages.map((msg, index) => { + // Compute bash_output grouping at render-time + const bashOutputGroup = computeBashOutputGroupInfo(deferredMessages, index); + + // For bash_output groups, use first message ID as expansion key + const groupKey = bashOutputGroup + ? deferredMessages[bashOutputGroup.firstIndex]?.id + : undefined; + const isGroupExpanded = groupKey ? expandedBashGroups.has(groupKey) : false; + + // Skip rendering middle items in a bash_output group (unless expanded) + if (bashOutputGroup?.position === "middle" && !isGroupExpanded) { + return null; + } + + const isAtCutoff = + editCutoffHistoryId !== undefined && + msg.type !== "history-hidden" && + msg.type !== "workspace-init" && + msg.historyId === editCutoffHistoryId; + + return ( + +
-
- {/* Show collapsed indicator after the first item in a bash_output group */} - {bashOutputGroup?.position === "first" && groupKey && ( - { - setExpandedBashGroups((prev) => { - const next = new Set(prev); - if (next.has(groupKey)) { - next.delete(groupKey); - } else { - next.add(groupKey); - } - return next; - }); - }} - /> - )} - {isAtCutoff && } - {shouldShowInterruptedBarrier(msg) && } -
- ); - })} - {/* Show RetryBarrier after the last message if needed */} - {showRetryBarrier && } - + > + +
+ {/* Show collapsed indicator after the first item in a bash_output group */} + {bashOutputGroup?.position === "first" && groupKey && ( + { + setExpandedBashGroups((prev) => { + const next = new Set(prev); + if (next.has(groupKey)) { + next.delete(groupKey); + } else { + next.add(groupKey); + } + return next; + }); + }} + /> + )} + {isAtCutoff && } + {shouldShowInterruptedBarrier(msg) && } +
+ ); + })} + {/* Show RetryBarrier after the last message if needed */} + {showRetryBarrierUI && } + + )} From 8a9221d87a457acf74dbc77e3e14e34a4813586f Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 18 Jan 2026 16:07:53 -0600 Subject: [PATCH 05/11] =?UTF-8?q?=F0=9F=A4=96=20fix:=20improve=20compactio?= =?UTF-8?q?n=20CTA=20contrast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/components/Messages/StreamErrorMessage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/browser/components/Messages/StreamErrorMessage.tsx b/src/browser/components/Messages/StreamErrorMessage.tsx index f6c8d7049d..572f85f836 100644 --- a/src/browser/components/Messages/StreamErrorMessage.tsx +++ b/src/browser/components/Messages/StreamErrorMessage.tsx @@ -128,7 +128,7 @@ const StreamErrorMessageWithRetry: React.FC = compactAndRetry.retryWithCompaction().catch(() => undefined); }} disabled={compactAndRetry.isRetryingWithCompaction} - className="border-warning/40 text-warning hover:bg-warning/10 hover:text-warning h-6 px-2 text-[10px]" + className="border-warning/50 text-foreground bg-warning/10 hover:bg-warning/15 hover:text-foreground h-6 px-2 text-[10px]" > {compactRetryLabel} @@ -137,7 +137,7 @@ const StreamErrorMessageWithRetry: React.FC = const compactionSuggestion = compactAndRetry.compactionSuggestion; const compactionDetails = showCompactRetry ? (
- Context window exceeded.{" "} + Context window exceeded.{" "} {compactionSuggestion ? ( compactionSuggestion.kind === "preferred" ? ( <> From e3cf6b2c547bf27cd8abf40d90b55ba5ff4e462e Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 18 Jan 2026 16:41:38 -0600 Subject: [PATCH 06/11] =?UTF-8?q?=F0=9F=A4=96=20fix:=20align=20compact=20r?= =?UTF-8?q?etry=20CTA=20and=20hide=20duplicates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/components/Messages/StreamErrorMessage.tsx | 4 +++- src/browser/hooks/useCompactAndRetry.ts | 4 ++++ src/browser/utils/chatCommands.ts | 3 +++ src/browser/utils/messages/StreamingMessageAggregator.ts | 1 + src/common/orpc/schemas/stream.ts | 1 + src/common/types/message.ts | 2 ++ src/node/services/agentSession.ts | 7 +++++++ 7 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/browser/components/Messages/StreamErrorMessage.tsx b/src/browser/components/Messages/StreamErrorMessage.tsx index 572f85f836..8109c17f67 100644 --- a/src/browser/components/Messages/StreamErrorMessage.tsx +++ b/src/browser/components/Messages/StreamErrorMessage.tsx @@ -84,7 +84,6 @@ const StreamErrorMessageBase: React.FC = (props) => {message.errorType}
- {compactRetryAction} {showCount && ( ×{message.errorCount} @@ -97,6 +96,9 @@ const StreamErrorMessageBase: React.FC = (props) => {message.error}
{compactionDetails} + {compactRetryAction ? ( +
{compactRetryAction}
+ ) : null}
); }; diff --git a/src/browser/hooks/useCompactAndRetry.ts b/src/browser/hooks/useCompactAndRetry.ts index de0ad5e09e..36b253b107 100644 --- a/src/browser/hooks/useCompactAndRetry.ts +++ b/src/browser/hooks/useCompactAndRetry.ts @@ -184,6 +184,8 @@ export function useCompactAndRetry(props: { workspaceId: string }): CompactAndRe if (source.compactionRequest) { const maxOutputTokens = source.compactionRequest.parsed.maxOutputTokens; const continueMessage = source.compactionRequest.parsed.continueMessage; + const continueMessageIsRetry = + source.compactionRequest.parsed.continueMessageIsRetry === true; const result = await executeCompaction({ api, @@ -192,6 +194,7 @@ export function useCompactAndRetry(props: { workspaceId: string }): CompactAndRe model: compactionSuggestion.modelId, maxOutputTokens, continueMessage, + continueMessageIsRetry, }); if (!result.success) { @@ -242,6 +245,7 @@ export function useCompactAndRetry(props: { workspaceId: string }): CompactAndRe sendMessageOptions, model: compactionSuggestion.modelId, continueMessage, + continueMessageIsRetry: true, }); if (!result.success) { diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index 9173777861..0189c4a2bf 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -613,6 +613,8 @@ export interface CompactionOptions { workspaceId: string; maxOutputTokens?: number; continueMessage?: ContinueMessage; + /** True when the continue message retries an already-sent user message. */ + continueMessageIsRetry?: boolean; model?: string; sendMessageOptions: SendMessageOptions; editMessageId?: string; @@ -662,6 +664,7 @@ export function prepareCompactionMessage(options: CompactionOptions): { model: effectiveModel, maxOutputTokens: options.maxOutputTokens, continueMessage: cm, + continueMessageIsRetry: options.continueMessageIsRetry, }; const metadata: MuxFrontendMetadata = { diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index 32fa2a136c..6f184e756a 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -1735,6 +1735,7 @@ export class StreamingMessageAggregator { model: muxMeta.parsed.model, maxOutputTokens: muxMeta.parsed.maxOutputTokens, continueMessage: muxMeta.parsed.continueMessage, + continueMessageIsRetry: muxMeta.parsed.continueMessageIsRetry, } satisfies CompactionRequestData, } : undefined; diff --git a/src/common/orpc/schemas/stream.ts b/src/common/orpc/schemas/stream.ts index 2c85327e55..7dbe7cf3cf 100644 --- a/src/common/orpc/schemas/stream.ts +++ b/src/common/orpc/schemas/stream.ts @@ -409,6 +409,7 @@ export const SendMessageOptionsSchema = z.object({ providerOptions: MuxProviderOptionsSchema.optional(), mode: AgentModeSchema.optional().catch(undefined), muxMetadata: z.any().optional(), // Black box + synthetic: z.boolean().optional(), experiments: ExperimentsSchema.optional(), /** * When true, workspace-specific agent definitions are disabled. diff --git a/src/common/types/message.ts b/src/common/types/message.ts index da8caee14d..e706880485 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -136,6 +136,8 @@ export interface CompactionRequestData { model?: string; // Custom model override for compaction maxOutputTokens?: number; continueMessage?: ContinueMessage; + /** True when the continue message retries an already-sent user message. */ + continueMessageIsRetry?: boolean; } /** diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 5f224ac508..d724e3a58a 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -73,6 +73,7 @@ interface CompactionRequestMetadata { type: "compaction-request"; parsed: { continueMessage?: ContinueMessage; + continueMessageIsRetry?: boolean; }; } @@ -550,6 +551,7 @@ export class AgentSession { timestamp: Date.now(), toolPolicy: typedToolPolicy, muxMetadata: typedMuxMetadata, // Pass through frontend metadata as black-box + synthetic: options?.synthetic ? true : undefined, }, additionalParts ); @@ -656,6 +658,7 @@ export class AgentSession { "exec"; // Build options for the queued message (strip compaction-specific fields) // agentId determines tool policy via resolveToolPolicyForAgent in aiService + const sanitizedOptions: Omit< SendMessageOptions, "muxMetadata" | "mode" | "editMessageId" | "imageParts" | "maxOutputTokens" @@ -669,6 +672,10 @@ export class AgentSession { disableWorkspaceAgents: options.disableWorkspaceAgents, }; + if (typedMuxMetadata.parsed.continueMessageIsRetry) { + sanitizedOptions.synthetic = true; + } + // Add image parts if present const continueImageParts = continueMessage.imageParts; if (continueImageParts && continueImageParts.length > 0) { From 917ef110f32c70ef5a37fd38e26a8aedd3466f95 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 18 Jan 2026 16:58:06 -0600 Subject: [PATCH 07/11] =?UTF-8?q?=F0=9F=A4=96=20fix:=20resume=20compaction?= =?UTF-8?q?=20retries=20without=20synthetic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/common/orpc/schemas/stream.ts | 1 - src/node/services/agentSession.ts | 34 +++++++++--- src/node/services/compactionHandler.ts | 71 ++++++++++++++++++++++++-- 3 files changed, 93 insertions(+), 13 deletions(-) diff --git a/src/common/orpc/schemas/stream.ts b/src/common/orpc/schemas/stream.ts index 7dbe7cf3cf..2c85327e55 100644 --- a/src/common/orpc/schemas/stream.ts +++ b/src/common/orpc/schemas/stream.ts @@ -409,7 +409,6 @@ export const SendMessageOptionsSchema = z.object({ providerOptions: MuxProviderOptionsSchema.optional(), mode: AgentModeSchema.optional().catch(undefined), muxMetadata: z.any().optional(), // Black box - synthetic: z.boolean().optional(), experiments: ExperimentsSchema.optional(), /** * When true, workspace-specific agent definitions are disabled. diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index d724e3a58a..7798e8876c 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -128,6 +128,7 @@ export class AgentSession { private readonly initListeners: Array<{ event: string; handler: (...args: unknown[]) => void }> = []; private disposed = false; + private pendingCompactionRetryOptions?: SendMessageOptions; private readonly messageQueue = new MessageQueue(); private readonly compactionHandler: CompactionHandler; @@ -551,7 +552,6 @@ export class AgentSession { timestamp: Date.now(), toolPolicy: typedToolPolicy, muxMetadata: typedMuxMetadata, // Pass through frontend metadata as black-box - synthetic: options?.synthetic ? true : undefined, }, additionalParts ); @@ -672,10 +672,6 @@ export class AgentSession { disableWorkspaceAgents: options.disableWorkspaceAgents, }; - if (typedMuxMetadata.parsed.continueMessageIsRetry) { - sanitizedOptions.synthetic = true; - } - // Add image parts if present const continueImageParts = continueMessage.imageParts; if (continueImageParts && continueImageParts.length > 0) { @@ -687,8 +683,12 @@ export class AgentSession { sanitizedOptions.muxMetadata = metadata; } - this.messageQueue.add(finalText, sanitizedOptions); - this.emitQueuedMessageChanged(); + if (typedMuxMetadata.parsed.continueMessageIsRetry) { + this.pendingCompactionRetryOptions = sanitizedOptions; + } else { + this.messageQueue.add(finalText, sanitizedOptions); + this.emitQueuedMessageChanged(); + } } if (this.disposed) { @@ -937,6 +937,7 @@ export class AgentSession { } this.activeCompactionRequest = undefined; + this.pendingCompactionRetryOptions = undefined; const streamError: StreamErrorMessage = { type: "stream-error", @@ -992,6 +993,7 @@ export class AgentSession { forward("usage-delta", (payload) => this.emitChatEvent(payload)); forward("stream-abort", (payload) => { this.activeCompactionRequest = undefined; + this.pendingCompactionRetryOptions = undefined; this.emitChatEvent(payload); }); forward("runtime-status", (payload) => this.emitChatEvent(payload)); @@ -999,6 +1001,11 @@ export class AgentSession { forward("stream-end", async (payload) => { this.activeCompactionRequest = undefined; const handled = await this.compactionHandler.handleCompletion(payload as StreamEndEvent); + const retryOptions = this.pendingCompactionRetryOptions; + const shouldRetryAfterCompaction = + handled && this.compactionHandler.consumeRetryAfterCompaction(); + this.pendingCompactionRetryOptions = undefined; + if (!handled) { this.emitChatEvent(payload); } else { @@ -1006,6 +1013,19 @@ export class AgentSession { // This allows the frontend to get updated postCompaction state this.onCompactionComplete?.(); } + + if (shouldRetryAfterCompaction && retryOptions) { + const retryResult = await this.resumeStream(retryOptions); + if (!retryResult.success) { + log.warn("Failed to resume after compaction retry", { + workspaceId: this.workspaceId, + error: retryResult.error, + }); + } else { + return; + } + } + // Stream end: auto-send queued messages this.sendQueuedMessages(); }); diff --git a/src/node/services/compactionHandler.ts b/src/node/services/compactionHandler.ts index 57c276e114..0ecfea443a 100644 --- a/src/node/services/compactionHandler.ts +++ b/src/node/services/compactionHandler.ts @@ -8,7 +8,12 @@ import type { Result } from "@/common/types/result"; import { Ok, Err } from "@/common/types/result"; import type { LanguageModelV2Usage } from "@ai-sdk/provider"; -import { createMuxMessage, type MuxMessage } from "@/common/types/message"; +import { + createMuxMessage, + prepareUserMessageForSend, + type ContinueMessage, + type MuxMessage, +} from "@/common/types/message"; import type { TelemetryService } from "@/node/services/telemetryService"; import { roundToBase2 } from "@/common/telemetry/utils"; import { log } from "@/node/services/log"; @@ -68,6 +73,9 @@ export class CompactionHandler { private readonly telemetryService?: TelemetryService; private readonly emitter: EventEmitter; private readonly processedCompactionRequestIds: Set = new Set(); + + /** Flag indicating a retry message should be resumed after compaction. */ + private retryAfterCompaction = false; private readonly onCompactionComplete?: () => void; /** Flag indicating post-compaction attachments should be generated on next turn */ @@ -110,6 +118,15 @@ export class CompactionHandler { return this.cachedFileDiffs.map((diff) => diff.path); } + /** + * Consume retry-after-compaction flag (clears after read). + */ + consumeRetryAfterCompaction(): boolean { + const shouldRetry = this.retryAfterCompaction; + this.retryAfterCompaction = false; + return shouldRetry; + } + /** * Handle compaction stream completion * @@ -125,12 +142,15 @@ export class CompactionHandler { const messages = historyResult.data; const lastUserMsg = [...messages].reverse().find((m) => m.role === "user"); - const isCompaction = lastUserMsg?.metadata?.muxMetadata?.type === "compaction-request"; + const muxMeta = lastUserMsg?.metadata?.muxMetadata; + const isCompaction = muxMeta?.type === "compaction-request"; if (!isCompaction || !lastUserMsg) { return false; } + this.retryAfterCompaction = false; + // Dedupe: If we've already processed this compaction-request, skip if (this.processedCompactionRequestIds.has(lastUserMsg.id)) { return true; @@ -175,9 +195,12 @@ export class CompactionHandler { } // Check if this was an idle-compaction (auto-triggered due to inactivity) - const muxMeta = lastUserMsg.metadata?.muxMetadata; const isIdleCompaction = muxMeta?.type === "compaction-request" && muxMeta.source === "idle-compaction"; + const retryContinueMessage = + muxMeta?.type === "compaction-request" && muxMeta.parsed.continueMessageIsRetry + ? muxMeta.parsed.continueMessage + : undefined; // Mark as processed before performing compaction this.processedCompactionRequestIds.add(lastUserMsg.id); @@ -186,7 +209,8 @@ export class CompactionHandler { summary, event.metadata, messages, - isIdleCompaction + isIdleCompaction, + retryContinueMessage ); if (!result.success) { log.error("Compaction failed:", result.error); @@ -238,7 +262,8 @@ export class CompactionHandler { systemMessageTokens?: number; }, messages: MuxMessage[], - isIdleCompaction = false + isIdleCompaction = false, + retryContinueMessage?: ContinueMessage ): Promise> { // CRITICAL: Delete partial.json BEFORE clearing history // This prevents a race condition where: @@ -304,6 +329,38 @@ export class CompactionHandler { return Err(`Failed to append summary: ${appendResult.error}`); } + let retryMessage: MuxMessage | null = null; + if (retryContinueMessage) { + const { finalText, metadata: retryMetadata } = + prepareUserMessageForSend(retryContinueMessage); + const retryImageParts = retryContinueMessage.imageParts?.map((part) => ({ + type: "file" as const, + url: part.url, + mediaType: part.mediaType, + })); + const retryMessageMetadata = retryMetadata + ? { timestamp: Date.now(), muxMetadata: retryMetadata } + : { timestamp: Date.now() }; + + retryMessage = createMuxMessage( + `user-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`, + "user", + finalText, + retryMessageMetadata, + retryImageParts + ); + + const retryAppendResult = await this.historyService.appendToHistory( + this.workspaceId, + retryMessage + ); + if (!retryAppendResult.success) { + return Err(`Failed to append retry message: ${retryAppendResult.error}`); + } + + this.retryAfterCompaction = true; + } + // Set flag to trigger post-compaction attachment injection on next turn this.postCompactionAttachmentsPending = true; @@ -319,6 +376,10 @@ export class CompactionHandler { // Emit summary message to frontend (add type: "message" for discriminated union) this.emitChatEvent({ ...summaryMessage, type: "message" }); + if (retryMessage) { + this.emitChatEvent({ ...retryMessage, type: "message" }); + } + return Ok(undefined); } From 3883adbcc5251e686cf259f135ceeb97cee12c5e Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 18 Jan 2026 18:02:54 -0600 Subject: [PATCH 08/11] fix: show compact retry command --- src/browser/hooks/useCompactAndRetry.ts | 2 ++ src/browser/utils/compaction/format.ts | 13 +++++++++++-- .../utils/messages/StreamingMessageAggregator.ts | 7 ++----- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/browser/hooks/useCompactAndRetry.ts b/src/browser/hooks/useCompactAndRetry.ts index 36b253b107..da78274037 100644 --- a/src/browser/hooks/useCompactAndRetry.ts +++ b/src/browser/hooks/useCompactAndRetry.ts @@ -195,6 +195,7 @@ export function useCompactAndRetry(props: { workspaceId: string }): CompactAndRe maxOutputTokens, continueMessage, continueMessageIsRetry, + editMessageId: source.id, }); if (!result.success) { @@ -246,6 +247,7 @@ export function useCompactAndRetry(props: { workspaceId: string }): CompactAndRe model: compactionSuggestion.modelId, continueMessage, continueMessageIsRetry: true, + editMessageId: source.id, }); if (!result.success) { diff --git a/src/browser/utils/compaction/format.ts b/src/browser/utils/compaction/format.ts index a33d9d53df..075badab04 100644 --- a/src/browser/utils/compaction/format.ts +++ b/src/browser/utils/compaction/format.ts @@ -30,8 +30,17 @@ export function buildCompactionEditText(request: { rawCommand: string; parsed: CompactionRequestData; }): string { - const continueText = request.parsed.continueMessage?.text; - if (typeof continueText === "string" && continueText.trim().length > 0) { + const continueMessage = request.parsed.continueMessage; + const continueText = continueMessage?.text; + const hasImages = (continueMessage?.imageParts?.length ?? 0) > 0; + const hasReviews = (continueMessage?.reviews?.length ?? 0) > 0; + const isDefaultResume = + typeof continueText === "string" && + continueText.trim() === "Continue" && + !hasImages && + !hasReviews; + + if (typeof continueText === "string" && continueText.trim().length > 0 && !isDefaultResume) { return `${request.rawCommand}\n${continueText}`; } return request.rawCommand; diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index 6f184e756a..dd7ac28291 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -7,6 +7,7 @@ import type { MuxFrontendMetadata, } from "@/common/types/message"; import { createMuxMessage } from "@/common/types/message"; +import { buildCompactionDisplayText } from "@/browser/utils/compaction/format"; import type { StreamStartEvent, StreamDeltaEvent, @@ -1740,10 +1741,6 @@ export class StreamingMessageAggregator { } : undefined; - const userDisplayText = - compactionRequest?.rawCommand ?? - (muxMeta?.type === "agent-skill" ? muxMeta.rawCommand : content); - // Extract reviews from muxMetadata for rich UI display (orthogonal to message type) const reviews = muxMeta?.reviews; @@ -1751,7 +1748,7 @@ export class StreamingMessageAggregator { type: "user", id: message.id, historyId: message.id, - content: userDisplayText, + content: compactionRequest ? buildCompactionDisplayText(compactionRequest) : content, imageParts: imageParts.length > 0 ? imageParts : undefined, historySequence, isSynthetic: message.metadata?.synthetic === true ? true : undefined, From ebf4711316fbddeae16ecc10de15c209899670cf Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 18 Jan 2026 18:09:46 -0600 Subject: [PATCH 09/11] fix: simplify compaction retry flow --- src/browser/hooks/useCompactAndRetry.ts | 5 -- src/browser/utils/chatCommands.ts | 3 - .../messages/StreamingMessageAggregator.ts | 1 - src/common/types/message.ts | 2 - src/node/services/agentSession.ts | 28 +------- src/node/services/compactionHandler.ts | 67 +------------------ 6 files changed, 5 insertions(+), 101 deletions(-) diff --git a/src/browser/hooks/useCompactAndRetry.ts b/src/browser/hooks/useCompactAndRetry.ts index da78274037..9a32f161f1 100644 --- a/src/browser/hooks/useCompactAndRetry.ts +++ b/src/browser/hooks/useCompactAndRetry.ts @@ -184,9 +184,6 @@ export function useCompactAndRetry(props: { workspaceId: string }): CompactAndRe if (source.compactionRequest) { const maxOutputTokens = source.compactionRequest.parsed.maxOutputTokens; const continueMessage = source.compactionRequest.parsed.continueMessage; - const continueMessageIsRetry = - source.compactionRequest.parsed.continueMessageIsRetry === true; - const result = await executeCompaction({ api, workspaceId: props.workspaceId, @@ -194,7 +191,6 @@ export function useCompactAndRetry(props: { workspaceId: string }): CompactAndRe model: compactionSuggestion.modelId, maxOutputTokens, continueMessage, - continueMessageIsRetry, editMessageId: source.id, }); @@ -246,7 +242,6 @@ export function useCompactAndRetry(props: { workspaceId: string }): CompactAndRe sendMessageOptions, model: compactionSuggestion.modelId, continueMessage, - continueMessageIsRetry: true, editMessageId: source.id, }); diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index 0189c4a2bf..9173777861 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -613,8 +613,6 @@ export interface CompactionOptions { workspaceId: string; maxOutputTokens?: number; continueMessage?: ContinueMessage; - /** True when the continue message retries an already-sent user message. */ - continueMessageIsRetry?: boolean; model?: string; sendMessageOptions: SendMessageOptions; editMessageId?: string; @@ -664,7 +662,6 @@ export function prepareCompactionMessage(options: CompactionOptions): { model: effectiveModel, maxOutputTokens: options.maxOutputTokens, continueMessage: cm, - continueMessageIsRetry: options.continueMessageIsRetry, }; const metadata: MuxFrontendMetadata = { diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index dd7ac28291..137bbb5cde 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -1736,7 +1736,6 @@ export class StreamingMessageAggregator { model: muxMeta.parsed.model, maxOutputTokens: muxMeta.parsed.maxOutputTokens, continueMessage: muxMeta.parsed.continueMessage, - continueMessageIsRetry: muxMeta.parsed.continueMessageIsRetry, } satisfies CompactionRequestData, } : undefined; diff --git a/src/common/types/message.ts b/src/common/types/message.ts index e706880485..da8caee14d 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -136,8 +136,6 @@ export interface CompactionRequestData { model?: string; // Custom model override for compaction maxOutputTokens?: number; continueMessage?: ContinueMessage; - /** True when the continue message retries an already-sent user message. */ - continueMessageIsRetry?: boolean; } /** diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 7798e8876c..9ee01d57ed 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -73,7 +73,6 @@ interface CompactionRequestMetadata { type: "compaction-request"; parsed: { continueMessage?: ContinueMessage; - continueMessageIsRetry?: boolean; }; } @@ -128,7 +127,6 @@ export class AgentSession { private readonly initListeners: Array<{ event: string; handler: (...args: unknown[]) => void }> = []; private disposed = false; - private pendingCompactionRetryOptions?: SendMessageOptions; private readonly messageQueue = new MessageQueue(); private readonly compactionHandler: CompactionHandler; @@ -683,12 +681,8 @@ export class AgentSession { sanitizedOptions.muxMetadata = metadata; } - if (typedMuxMetadata.parsed.continueMessageIsRetry) { - this.pendingCompactionRetryOptions = sanitizedOptions; - } else { - this.messageQueue.add(finalText, sanitizedOptions); - this.emitQueuedMessageChanged(); - } + this.messageQueue.add(finalText, sanitizedOptions); + this.emitQueuedMessageChanged(); } if (this.disposed) { @@ -937,7 +931,6 @@ export class AgentSession { } this.activeCompactionRequest = undefined; - this.pendingCompactionRetryOptions = undefined; const streamError: StreamErrorMessage = { type: "stream-error", @@ -993,7 +986,6 @@ export class AgentSession { forward("usage-delta", (payload) => this.emitChatEvent(payload)); forward("stream-abort", (payload) => { this.activeCompactionRequest = undefined; - this.pendingCompactionRetryOptions = undefined; this.emitChatEvent(payload); }); forward("runtime-status", (payload) => this.emitChatEvent(payload)); @@ -1001,10 +993,6 @@ export class AgentSession { forward("stream-end", async (payload) => { this.activeCompactionRequest = undefined; const handled = await this.compactionHandler.handleCompletion(payload as StreamEndEvent); - const retryOptions = this.pendingCompactionRetryOptions; - const shouldRetryAfterCompaction = - handled && this.compactionHandler.consumeRetryAfterCompaction(); - this.pendingCompactionRetryOptions = undefined; if (!handled) { this.emitChatEvent(payload); @@ -1014,18 +1002,6 @@ export class AgentSession { this.onCompactionComplete?.(); } - if (shouldRetryAfterCompaction && retryOptions) { - const retryResult = await this.resumeStream(retryOptions); - if (!retryResult.success) { - log.warn("Failed to resume after compaction retry", { - workspaceId: this.workspaceId, - error: retryResult.error, - }); - } else { - return; - } - } - // Stream end: auto-send queued messages this.sendQueuedMessages(); }); diff --git a/src/node/services/compactionHandler.ts b/src/node/services/compactionHandler.ts index 0ecfea443a..51021836e3 100644 --- a/src/node/services/compactionHandler.ts +++ b/src/node/services/compactionHandler.ts @@ -8,12 +8,7 @@ import type { Result } from "@/common/types/result"; import { Ok, Err } from "@/common/types/result"; import type { LanguageModelV2Usage } from "@ai-sdk/provider"; -import { - createMuxMessage, - prepareUserMessageForSend, - type ContinueMessage, - type MuxMessage, -} from "@/common/types/message"; +import { createMuxMessage, type MuxMessage } from "@/common/types/message"; import type { TelemetryService } from "@/node/services/telemetryService"; import { roundToBase2 } from "@/common/telemetry/utils"; import { log } from "@/node/services/log"; @@ -74,8 +69,6 @@ export class CompactionHandler { private readonly emitter: EventEmitter; private readonly processedCompactionRequestIds: Set = new Set(); - /** Flag indicating a retry message should be resumed after compaction. */ - private retryAfterCompaction = false; private readonly onCompactionComplete?: () => void; /** Flag indicating post-compaction attachments should be generated on next turn */ @@ -118,15 +111,6 @@ export class CompactionHandler { return this.cachedFileDiffs.map((diff) => diff.path); } - /** - * Consume retry-after-compaction flag (clears after read). - */ - consumeRetryAfterCompaction(): boolean { - const shouldRetry = this.retryAfterCompaction; - this.retryAfterCompaction = false; - return shouldRetry; - } - /** * Handle compaction stream completion * @@ -149,8 +133,6 @@ export class CompactionHandler { return false; } - this.retryAfterCompaction = false; - // Dedupe: If we've already processed this compaction-request, skip if (this.processedCompactionRequestIds.has(lastUserMsg.id)) { return true; @@ -197,11 +179,6 @@ export class CompactionHandler { // Check if this was an idle-compaction (auto-triggered due to inactivity) const isIdleCompaction = muxMeta?.type === "compaction-request" && muxMeta.source === "idle-compaction"; - const retryContinueMessage = - muxMeta?.type === "compaction-request" && muxMeta.parsed.continueMessageIsRetry - ? muxMeta.parsed.continueMessage - : undefined; - // Mark as processed before performing compaction this.processedCompactionRequestIds.add(lastUserMsg.id); @@ -209,8 +186,7 @@ export class CompactionHandler { summary, event.metadata, messages, - isIdleCompaction, - retryContinueMessage + isIdleCompaction ); if (!result.success) { log.error("Compaction failed:", result.error); @@ -262,8 +238,7 @@ export class CompactionHandler { systemMessageTokens?: number; }, messages: MuxMessage[], - isIdleCompaction = false, - retryContinueMessage?: ContinueMessage + isIdleCompaction = false ): Promise> { // CRITICAL: Delete partial.json BEFORE clearing history // This prevents a race condition where: @@ -329,38 +304,6 @@ export class CompactionHandler { return Err(`Failed to append summary: ${appendResult.error}`); } - let retryMessage: MuxMessage | null = null; - if (retryContinueMessage) { - const { finalText, metadata: retryMetadata } = - prepareUserMessageForSend(retryContinueMessage); - const retryImageParts = retryContinueMessage.imageParts?.map((part) => ({ - type: "file" as const, - url: part.url, - mediaType: part.mediaType, - })); - const retryMessageMetadata = retryMetadata - ? { timestamp: Date.now(), muxMetadata: retryMetadata } - : { timestamp: Date.now() }; - - retryMessage = createMuxMessage( - `user-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`, - "user", - finalText, - retryMessageMetadata, - retryImageParts - ); - - const retryAppendResult = await this.historyService.appendToHistory( - this.workspaceId, - retryMessage - ); - if (!retryAppendResult.success) { - return Err(`Failed to append retry message: ${retryAppendResult.error}`); - } - - this.retryAfterCompaction = true; - } - // Set flag to trigger post-compaction attachment injection on next turn this.postCompactionAttachmentsPending = true; @@ -376,10 +319,6 @@ export class CompactionHandler { // Emit summary message to frontend (add type: "message" for discriminated union) this.emitChatEvent({ ...summaryMessage, type: "message" }); - if (retryMessage) { - this.emitChatEvent({ ...retryMessage, type: "message" }); - } - return Ok(undefined); } From d73482224fd469ecbb74b78ada15a72ba44a8057 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 19 Jan 2026 09:35:33 -0600 Subject: [PATCH 10/11] fix: tidy compaction retry display --- src/browser/hooks/useCompactAndRetry.ts | 4 +-- src/browser/utils/chatCommands.ts | 4 +-- src/browser/utils/compaction/format.ts | 45 +++++++++++++++++++------ src/common/types/message.ts | 12 +++++++ src/node/services/agentSession.ts | 9 +++++ 5 files changed, 59 insertions(+), 15 deletions(-) diff --git a/src/browser/hooks/useCompactAndRetry.ts b/src/browser/hooks/useCompactAndRetry.ts index 9a32f161f1..154f4be76a 100644 --- a/src/browser/hooks/useCompactAndRetry.ts +++ b/src/browser/hooks/useCompactAndRetry.ts @@ -6,6 +6,7 @@ import { useWorkspaceState } from "@/browser/stores/WorkspaceStore"; import { buildCompactionEditText, formatCompactionCommandLine, + getCompactionContinueText, } from "@/browser/utils/compaction/format"; import { getExplicitCompactionSuggestion, @@ -211,8 +212,7 @@ export function useCompactAndRetry(props: { workspaceId: string }): CompactAndRe }, }); - const shouldAppendNewline = - !continueMessage?.text || continueMessage.text.trim().length === 0; + const shouldAppendNewline = !getCompactionContinueText(continueMessage); insertIntoChatInput( fallbackText + (shouldAppendNewline ? "\n" : ""), diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index 9173777861..f59a99ee09 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -14,6 +14,7 @@ import { type CompactionRequestData, type ContinueMessage, buildContinueMessage, + isDefaultContinueMessage, } from "@/common/types/message"; import type { ReviewNoteData } from "@/common/types/review"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; @@ -647,8 +648,7 @@ export function prepareCompactionMessage(options: CompactionOptions): { // misread it as a competing instruction. We still keep it in metadata so the backend resumes. // Only treat it as the default resume when there's no other queued content (images/reviews). const cm = options.continueMessage; - const isDefaultResume = - cm?.text?.trim() === "Continue" && !cm?.imageParts?.length && !cm?.reviews?.length; + const isDefaultResume = isDefaultContinueMessage(cm); if (cm && !isDefaultResume) { messageText += `\n\nThe user wants to continue with: ${cm.text}`; diff --git a/src/browser/utils/compaction/format.ts b/src/browser/utils/compaction/format.ts index 075badab04..f9d400f8c1 100644 --- a/src/browser/utils/compaction/format.ts +++ b/src/browser/utils/compaction/format.ts @@ -1,4 +1,5 @@ import type { CompactionRequestData } from "@/common/types/message"; +import { isDefaultContinueMessage } from "@/common/types/message"; /** * Format compaction command *line* for display. @@ -20,6 +21,22 @@ export function formatCompactionCommandLine(options: { return cmd; } +/** + * Return the visible continue text for a compaction request. + * Hides the default resume sentinel ("Continue") and empty text. + */ +export function getCompactionContinueText( + continueMessage?: CompactionRequestData["continueMessage"] +): string | null { + if (!continueMessage) return null; + if (isDefaultContinueMessage(continueMessage)) return null; + const continueText = continueMessage.text; + if (typeof continueText !== "string" || continueText.trim().length === 0) { + return null; + } + return continueText; +} + /** * Build the text shown in the editor when editing a /compact request. * @@ -30,18 +47,24 @@ export function buildCompactionEditText(request: { rawCommand: string; parsed: CompactionRequestData; }): string { - const continueMessage = request.parsed.continueMessage; - const continueText = continueMessage?.text; - const hasImages = (continueMessage?.imageParts?.length ?? 0) > 0; - const hasReviews = (continueMessage?.reviews?.length ?? 0) > 0; - const isDefaultResume = - typeof continueText === "string" && - continueText.trim() === "Continue" && - !hasImages && - !hasReviews; - - if (typeof continueText === "string" && continueText.trim().length > 0 && !isDefaultResume) { + const continueText = getCompactionContinueText(request.parsed.continueMessage); + if (continueText) { return `${request.rawCommand}\n${continueText}`; } return request.rawCommand; } + +/** + * Build the text shown in user message bubbles for a /compact request. + * Uses a hard line break so the command and payload render on separate lines. + */ +export function buildCompactionDisplayText(request: { + rawCommand: string; + parsed: CompactionRequestData; +}): string { + const continueText = getCompactionContinueText(request.parsed.continueMessage); + if (continueText) { + return `${request.rawCommand} \n${continueText}`; + } + return request.rawCommand; +} diff --git a/src/common/types/message.ts b/src/common/types/message.ts index da8caee14d..6e573b9a67 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -98,6 +98,18 @@ export type PersistedContinueMessage = mode?: "exec" | "plan"; }; +/** + * True when the continue message is the default resume sentinel ("Continue") + * with no attachments. + */ +export function isDefaultContinueMessage(message?: Partial): boolean { + if (!message) return false; + const text = typeof message.text === "string" ? message.text.trim() : ""; + const hasImages = (message.imageParts?.length ?? 0) > 0; + const hasReviews = (message.reviews?.length ?? 0) > 0; + return text === "Continue" && !hasImages && !hasReviews; +} + /** * Rebuild a ContinueMessage from persisted data. * Use this when reading from storage/history where the data may have been diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 9ee01d57ed..157a2c33be 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -926,12 +926,17 @@ export class AgentSession { error: string; errorType?: string; }): Promise { + const hadCompactionRequest = this.activeCompactionRequest !== undefined; if (await this.maybeRetryCompactionOnContextExceeded(data)) { return; } this.activeCompactionRequest = undefined; + if (hadCompactionRequest && !this.disposed) { + this.clearQueue(); + } + const streamError: StreamErrorMessage = { type: "stream-error", messageId: data.messageId, @@ -985,7 +990,11 @@ export class AgentSession { forward("reasoning-end", (payload) => this.emitChatEvent(payload)); forward("usage-delta", (payload) => this.emitChatEvent(payload)); forward("stream-abort", (payload) => { + const hadCompactionRequest = this.activeCompactionRequest !== undefined; this.activeCompactionRequest = undefined; + if (hadCompactionRequest && !this.disposed) { + this.clearQueue(); + } this.emitChatEvent(payload); }); forward("runtime-status", (payload) => this.emitChatEvent(payload)); From 001855f068284e44e31ca96f9c77b3fa92167402 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 19 Jan 2026 10:07:50 -0600 Subject: [PATCH 11/11] fix: dedupe compaction continue queue --- src/node/services/agentSession.ts | 10 ++++++-- src/node/services/messageQueue.test.ts | 21 ++++++++++++++++ src/node/services/messageQueue.ts | 33 +++++++++++++++++++++++++- 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 157a2c33be..08094b627b 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -681,8 +681,14 @@ export class AgentSession { sanitizedOptions.muxMetadata = metadata; } - this.messageQueue.add(finalText, sanitizedOptions); - this.emitQueuedMessageChanged(); + const dedupeKey = JSON.stringify({ + text: finalText.trim(), + images: (continueImageParts ?? []).map((image) => `${image.mediaType}:${image.url}`), + }); + + if (this.messageQueue.addOnce(finalText, sanitizedOptions, dedupeKey)) { + this.emitQueuedMessageChanged(); + } } if (this.disposed) { diff --git a/src/node/services/messageQueue.test.ts b/src/node/services/messageQueue.test.ts index 6487ddbc72..257dbc1dbb 100644 --- a/src/node/services/messageQueue.test.ts +++ b/src/node/services/messageQueue.test.ts @@ -159,6 +159,27 @@ describe("MessageQueue", () => { }); }); + describe("addOnce", () => { + it("should dedupe repeated entries by key", () => { + const image = { url: "data:image/png;base64,abc", mediaType: "image/png" }; + const addedFirst = queue.addOnce( + "Follow up", + { model: "gpt-4", imageParts: [image] }, + "follow-up" + ); + const addedSecond = queue.addOnce( + "Follow up", + { model: "gpt-4", imageParts: [image] }, + "follow-up" + ); + + expect(addedFirst).toBe(true); + expect(addedSecond).toBe(false); + expect(queue.getMessages()).toEqual(["Follow up"]); + expect(queue.getImageParts()).toEqual([image]); + }); + }); + describe("multi-message batching", () => { it("should batch multiple follow-up messages", () => { queue.add("First message"); diff --git a/src/node/services/messageQueue.ts b/src/node/services/messageQueue.ts index 22ff7aeb87..f1e82d1484 100644 --- a/src/node/services/messageQueue.ts +++ b/src/node/services/messageQueue.ts @@ -66,6 +66,7 @@ export class MessageQueue { private firstMuxMetadata?: unknown; private latestOptions?: SendMessageOptions; private accumulatedImages: ImagePart[] = []; + private dedupeKeys: Set = new Set(); /** * Check if the queue currently contains a compaction request. @@ -82,12 +83,39 @@ export class MessageQueue { * @throws Error if trying to add a compaction request when queue already has messages */ add(message: string, options?: SendMessageOptions & { imageParts?: ImagePart[] }): void { + this.addInternal(message, options); + } + + /** + * Add a message to the queue once, keyed by dedupeKey. + * Returns true if the message was queued. + */ + addOnce( + message: string, + options?: SendMessageOptions & { imageParts?: ImagePart[] }, + dedupeKey?: string + ): boolean { + if (dedupeKey !== undefined && this.dedupeKeys.has(dedupeKey)) { + return false; + } + + const didAdd = this.addInternal(message, options); + if (didAdd && dedupeKey !== undefined) { + this.dedupeKeys.add(dedupeKey); + } + return didAdd; + } + + private addInternal( + message: string, + options?: SendMessageOptions & { imageParts?: ImagePart[] } + ): boolean { const trimmedMessage = message.trim(); const hasImages = options?.imageParts && options.imageParts.length > 0; // Reject if both text and images are empty if (trimmedMessage.length === 0 && !hasImages) { - return; + return false; } const incomingIsCompaction = isCompactionMetadata(options?.muxMetadata); @@ -139,6 +167,8 @@ export class MessageQueue { this.accumulatedImages.push(...imageParts); } } + + return true; } /** @@ -218,6 +248,7 @@ export class MessageQueue { this.firstMuxMetadata = undefined; this.latestOptions = undefined; this.accumulatedImages = []; + this.dedupeKeys.clear(); } /**