Skip to content

Commit 48c25c0

Browse files
authored
🤖 Fix auto-compact-continue regression (#417)
## Problem Auto-compact-continue feature stopped working after compaction completed. The hook that automatically sends follow-up messages wasn't detecting the compacted state, breaking conversation flow in long-running sessions. ## Root Cause The `useAutoCompactContinue` hook checked `messages[0]` for the `isCompacted` flag, but `messages[0]` is the `workspace-init` UI metadata message, not the compacted assistant message. The check always failed because workspace-init has no `isCompacted` flag. **What the code was doing:** ```typescript const isSingleCompacted = state.messages.length === 1 && // ❌ Includes workspace-init state.messages[0]?.type === "assistant" && // ❌ False - it's workspace-init state.messages[0].isCompacted === true; ``` **State after compaction:** - `messages[0]` = workspace-init (UI metadata) - `messages[1]` = compacted assistant message ✅ ## Solution Filter out `workspace-init` messages before checking compaction state: ```typescript // Filter to conversation messages only const cmuxMessages = state.messages.filter((m) => m.type !== "workspace-init"); const isSingleCompacted = cmuxMessages.length === 1 && // ✅ Only conversation messages cmuxMessages[0]?.type === "assistant" && cmuxMessages[0].isCompacted === true; ``` ## Additional Improvements **Type Safety**: Added `isCmuxMessage()` type guard following existing pattern. Replaced manual `"role" in data && !("type" in data)` checks that were causing regressions. **Better Logging**: Added workspace `name` to `WorkspaceState` for debugging. Removed verbose debug logs after fix confirmed. ## Testing - All 763 tests passing ✅ - Manual verification: Compaction now triggers auto-continue as expected _Generated with `cmux`_
1 parent 110962b commit 48c25c0

File tree

4 files changed

+39
-27
lines changed

4 files changed

+39
-27
lines changed

src/hooks/useAutoCompactContinue.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,12 @@ export function useAutoCompactContinue() {
4141
// Check all workspaces for completed compaction
4242
for (const [workspaceId, state] of newStates) {
4343
// Detect if workspace is in "single compacted message" state
44+
// Skip workspace-init messages since they're UI-only metadata
45+
const cmuxMessages = state.messages.filter((m) => m.type !== "workspace-init");
4446
const isSingleCompacted =
45-
state.messages.length === 1 &&
46-
state.messages[0].type === "assistant" &&
47-
state.messages[0].isCompacted === true;
47+
cmuxMessages.length === 1 &&
48+
cmuxMessages[0]?.type === "assistant" &&
49+
cmuxMessages[0].isCompacted === true;
4850

4951
if (!isSingleCompacted) {
5052
// Workspace no longer in compacted state - no action needed
@@ -74,11 +76,6 @@ export function useAutoCompactContinue() {
7476
// Mark THIS RESULT as processed before sending to prevent duplicates
7577
processedMessageIds.current.add(idForGuard);
7678

77-
console.log(
78-
`[useAutoCompactContinue] Sending continue message for ${workspaceId}:`,
79-
continueMessage
80-
);
81-
8279
// Build options and send message directly
8380
const options = buildSendMessageOptions(workspaceId);
8481
void (async () => {

src/stores/WorkspaceStore.ts

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { updatePersistedState } from "@/hooks/usePersistedState";
99
import { getRetryStateKey } from "@/constants/storage";
1010
import { CUSTOM_EVENTS } from "@/constants/events";
1111
import { useSyncExternalStore } from "react";
12-
import { isCaughtUpMessage, isStreamError, isDeleteMessage } from "@/types/ipc";
12+
import { isCaughtUpMessage, isStreamError, isDeleteMessage, isCmuxMessage } from "@/types/ipc";
1313
import { MapStore } from "./MapStore";
1414
import { createDisplayUsage } from "@/utils/tokens/displayUsage";
1515
import { WorkspaceConsumerManager } from "./WorkspaceConsumerManager";
@@ -20,6 +20,7 @@ import { getCancelledCompactionKey } from "@/constants/storage";
2020
import { isCompactingStream, findCompactionRequestMessage } from "@/utils/compaction/handler";
2121

2222
export interface WorkspaceState {
23+
name: string; // User-facing workspace name (e.g., "feature-branch")
2324
messages: DisplayedMessage[];
2425
canInterrupt: boolean;
2526
isCompacting: boolean;
@@ -108,6 +109,7 @@ export class WorkspaceStore {
108109
private caughtUp = new Map<string, boolean>();
109110
private historicalMessages = new Map<string, CmuxMessage[]>();
110111
private pendingStreamEvents = new Map<string, WorkspaceChatMessage[]>();
112+
private workspaceMetadata = new Map<string, FrontendWorkspaceMetadata>(); // Store metadata for name lookup
111113

112114
/**
113115
* Map of event types to their handlers. This is the single source of truth for:
@@ -335,8 +337,10 @@ export class WorkspaceStore {
335337
const isCaughtUp = this.caughtUp.get(workspaceId) ?? false;
336338
const activeStreams = aggregator.getActiveStreams();
337339
const messages = aggregator.getAllMessages();
340+
const metadata = this.workspaceMetadata.get(workspaceId);
338341

339342
return {
343+
name: metadata?.name ?? workspaceId, // Fall back to ID if metadata missing
340344
messages: aggregator.getDisplayedMessages(),
341345
canInterrupt: activeStreams.length > 0,
342346
isCompacting: aggregator.isCompacting(),
@@ -730,6 +734,9 @@ export class WorkspaceStore {
730734
return;
731735
}
732736

737+
// Store metadata for name lookup
738+
this.workspaceMetadata.set(workspaceId, metadata);
739+
733740
const aggregator = this.getOrCreateAggregator(workspaceId, metadata.createdAt);
734741

735742
// Initialize recency cache and bump derived store immediately
@@ -958,23 +965,26 @@ export class WorkspaceStore {
958965
}
959966

960967
// Regular messages (CmuxMessage without type field)
961-
const isCaughtUp = this.caughtUp.get(workspaceId) ?? false;
962-
if (!isCaughtUp && "role" in data && !("type" in data)) {
963-
// Buffer historical CmuxMessages
964-
const historicalMsgs = this.historicalMessages.get(workspaceId) ?? [];
965-
historicalMsgs.push(data);
966-
this.historicalMessages.set(workspaceId, historicalMsgs);
967-
} else if (isCaughtUp && "role" in data) {
968-
// Process live events immediately (after history loaded)
969-
// Check for role field to ensure this is a CmuxMessage
970-
aggregator.handleMessage(data);
971-
this.states.bump(workspaceId);
972-
this.checkAndBumpRecencyIfChanged();
973-
} else if ("role" in data || "type" in data) {
974-
// Unexpected: message with role/type field didn't match any condition
975-
console.error("[WorkspaceStore] Message not processed - unexpected state", {
968+
if (isCmuxMessage(data)) {
969+
const isCaughtUp = this.caughtUp.get(workspaceId) ?? false;
970+
if (!isCaughtUp) {
971+
// Buffer historical CmuxMessages
972+
const historicalMsgs = this.historicalMessages.get(workspaceId) ?? [];
973+
historicalMsgs.push(data);
974+
this.historicalMessages.set(workspaceId, historicalMsgs);
975+
} else {
976+
// Process live events immediately (after history loaded)
977+
aggregator.handleMessage(data);
978+
this.states.bump(workspaceId);
979+
this.checkAndBumpRecencyIfChanged();
980+
}
981+
return;
982+
}
983+
984+
// If we reach here, unknown message type - log for debugging
985+
if ("role" in data || "type" in data) {
986+
console.error("[WorkspaceStore] Unknown message type - not processed", {
976987
workspaceId,
977-
isCaughtUp,
978988
hasRole: "role" in data,
979989
hasType: "type" in data,
980990
type: "type" in data ? (data as { type: string }).type : undefined,

src/types/ipc.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,11 @@ export function isReasoningEnd(msg: WorkspaceChatMessage): msg is ReasoningEndEv
149149
return "type" in msg && msg.type === "reasoning-end";
150150
}
151151

152+
// Type guard for CmuxMessage (messages with role but no type field)
153+
export function isCmuxMessage(msg: WorkspaceChatMessage): msg is CmuxMessage {
154+
return "role" in msg && !("type" in msg);
155+
}
156+
152157
// Type guards for init events
153158
export function isInitStart(
154159
msg: WorkspaceChatMessage

src/utils/messages/StreamingMessageAggregator.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import type {
1414
import type { TodoItem } from "@/types/tools";
1515

1616
import type { WorkspaceChatMessage, StreamErrorMessage, DeleteMessage } from "@/types/ipc";
17-
import { isInitStart, isInitOutput, isInitEnd } from "@/types/ipc";
17+
import { isInitStart, isInitOutput, isInitEnd, isCmuxMessage } from "@/types/ipc";
1818
import type {
1919
DynamicToolPart,
2020
DynamicToolPartPending,
@@ -543,7 +543,7 @@ export class StreamingMessageAggregator {
543543

544544
// Handle regular messages (user messages, historical messages)
545545
// Check if it's a CmuxMessage (has role property but no type)
546-
if ("role" in data && !("type" in data)) {
546+
if (isCmuxMessage(data)) {
547547
const incomingMessage = data;
548548

549549
// Smart replacement logic for edits:

0 commit comments

Comments
 (0)