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();
}
/**