diff --git a/src/hooks/useAutoCompactContinue.ts b/src/hooks/useAutoCompactContinue.ts index 6a857d130..bb23f0406 100644 --- a/src/hooks/useAutoCompactContinue.ts +++ b/src/hooks/useAutoCompactContinue.ts @@ -1,5 +1,5 @@ import { useRef, useEffect } from "react"; -import { useWorkspaceStoreRaw, type WorkspaceState } from "@/stores/WorkspaceStore"; +import { useWorkspaceStoreRaw } from "@/stores/WorkspaceStore"; import { buildSendMessageOptions } from "@/hooks/useSendMessageOptions"; /** @@ -28,8 +28,6 @@ export function useAutoCompactContinue() { // re-rendering AppInner on every workspace state change. This hook only needs // to react when messages change to a single compacted message state. const store = useWorkspaceStoreRaw(); - const workspaceStatesRef = useRef>(new Map()); - // Track which specific compaction summary messages we've already processed. // Key insight: Each compaction creates a unique message. Track by message ID, // not workspace ID, to prevent processing the same compaction result multiple times. @@ -39,7 +37,6 @@ export function useAutoCompactContinue() { // Update ref and check for auto-continue condition const checkAutoCompact = () => { const newStates = store.getAllStates(); - workspaceStatesRef.current = newStates; // Check all workspaces for completed compaction for (const [workspaceId, state] of newStates) { @@ -59,22 +56,23 @@ export function useAutoCompactContinue() { // After compaction, history is replaced with a single summary message // The summary message has compaction-result metadata with the continueMessage const summaryMessage = state.cmuxMessages[0]; // Single compacted message - const messageId = summaryMessage.id; - - // Have we already processed this specific compaction message? - // This check is race-safe because message IDs are unique and immutable. - if (processedMessageIds.current.has(messageId)) continue; - const cmuxMeta = summaryMessage?.metadata?.cmuxMetadata; const continueMessage = cmuxMeta?.type === "compaction-result" ? cmuxMeta.continueMessage : undefined; if (!continueMessage) continue; - // Mark THIS MESSAGE as processed before sending - // Multiple concurrent checkAutoCompact() calls will all see the same message ID, - // so only the first call that reaches this point will proceed - processedMessageIds.current.add(messageId); + // Prefer compaction-request ID for idempotency; fall back to summary message ID + const idForGuard = + cmuxMeta?.type === "compaction-result" && cmuxMeta.requestId + ? `req:${cmuxMeta.requestId}` + : `msg:${summaryMessage.id}`; + + // Have we already processed this specific compaction result? + if (processedMessageIds.current.has(idForGuard)) continue; + + // Mark THIS RESULT as processed before sending to prevent duplicates + processedMessageIds.current.add(idForGuard); console.log( `[useAutoCompactContinue] Sending continue message for ${workspaceId}:`, @@ -86,7 +84,7 @@ export function useAutoCompactContinue() { window.api.workspace.sendMessage(workspaceId, continueMessage, options).catch((error) => { console.error("Failed to send continue message:", error); // If sending failed, remove from processed set to allow retry - processedMessageIds.current.delete(messageId); + processedMessageIds.current.delete(idForGuard); }); } }; diff --git a/src/stores/WorkspaceStore.ts b/src/stores/WorkspaceStore.ts index be1f0218f..afc265210 100644 --- a/src/stores/WorkspaceStore.ts +++ b/src/stores/WorkspaceStore.ts @@ -417,6 +417,9 @@ export class WorkspaceStore { * Handle compact_summary tool completion. * Returns true if compaction was handled (caller should early return). */ + // Track processed compaction-request IDs to dedupe performCompaction across duplicated events + private processedCompactionRequestIds = new Set(); + private handleCompactionCompletion( workspaceId: string, aggregator: StreamingMessageAggregator, @@ -430,6 +433,17 @@ export class WorkspaceStore { return false; } + // Extract the compaction-request message to identify this compaction run + const compactionRequestMsg = findCompactionRequestMessage(aggregator); + if (!compactionRequestMsg) { + return false; + } + + // Dedupe: If we've already processed this compaction-request, skip re-running + if (this.processedCompactionRequestIds.has(compactionRequestMsg.id)) { + return true; // Already handled compaction for this request + } + // Extract the summary text from the assistant's response const summary = aggregator.getCompactionSummary(data.messageId); if (!summary) { @@ -437,6 +451,9 @@ export class WorkspaceStore { return false; } + // Mark this compaction-request as processed before performing compaction + this.processedCompactionRequestIds.add(compactionRequestMsg.id); + this.performCompaction(workspaceId, aggregator, data, summary); return true; } @@ -544,7 +561,7 @@ export class WorkspaceStore { : undefined, // Store continueMessage in summary so it survives history replacement cmuxMetadata: continueMessage - ? { type: "compaction-result", continueMessage } + ? { type: "compaction-result", continueMessage, requestId: compactRequestMsg?.id } : { type: "normal" }, } ); diff --git a/src/types/message.ts b/src/types/message.ts index a307766a4..4da548409 100644 --- a/src/types/message.ts +++ b/src/types/message.ts @@ -21,6 +21,7 @@ export type CmuxFrontendMetadata = | { type: "compaction-result"; continueMessage: string; // Message to send after compaction completes + requestId?: string; // ID of the compaction-request user message that produced this summary (for idempotency) } | { type: "normal"; // Regular messages