Skip to content

Commit e70d6d8

Browse files
committed
🤖 fix: prevent compaction infinite loop and stale usage display
Two related bugs were causing compaction to loop forever: 1. **Message queue overwrote compaction metadata**: When a user typed a follow-up message while a compaction was queued, the queue would overwrite latestOptions, losing the muxMetadata that marks the message as a compaction-request. The compaction would then be sent as a normal message, never triggering actual history replacement. Fix: Follow-up messages added after a compaction request are now merged into the compaction's continueMessage field rather than overwriting options. This preserves compaction metadata and sends follow-ups after compaction completes. 2. **Usage display showed pre-compaction context**: After compaction, the summary message's usage field contained the pre-compaction context size (since that's what was sent to generate the summary). This inflated the context window display, making it appear usage was still high and triggering auto-compaction again. Fix: Skip compacted messages when computing lastContextUsage. The summary's usage reflects historical cost, not current context. _Generated with `mux`_
1 parent ac30c74 commit e70d6d8

File tree

3 files changed

+221
-21
lines changed

3 files changed

+221
-21
lines changed

src/browser/stores/WorkspaceStore.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,10 +476,16 @@ export class WorkspaceStore {
476476

477477
// Get last message's context usage for context window display
478478
// Uses contextUsage (last step) if available, falls back to usage for old messages
479+
// Skips compacted messages - their usage reflects pre-compaction context, not current
479480
const lastContextUsage = (() => {
480481
for (let i = messages.length - 1; i >= 0; i--) {
481482
const msg = messages[i];
482483
if (msg.role === "assistant") {
484+
// Skip compacted messages - their usage is from pre-compaction context
485+
// and doesn't reflect current context window size
486+
if (msg.metadata?.compacted) {
487+
continue;
488+
}
483489
const rawUsage = msg.metadata?.contextUsage ?? msg.metadata?.usage;
484490
const providerMeta =
485491
msg.metadata?.contextProviderMetadata ?? msg.metadata?.providerMetadata;

src/node/services/messageQueue.test.ts

Lines changed: 86 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ describe("MessageQueue", () => {
3535
expect(queue.getDisplayText()).toBe("/compact -t 3000");
3636
});
3737

38-
it("should show actual messages when compaction is added after normal message", () => {
38+
it("should throw when adding compaction after normal message", () => {
3939
queue.add("First message");
4040

4141
const metadata: MuxFrontendMetadata = {
@@ -49,11 +49,11 @@ describe("MessageQueue", () => {
4949
muxMetadata: metadata,
5050
};
5151

52-
queue.add("Summarize this conversation...", options);
53-
54-
// When multiple messages are queued, compaction metadata is lost when sent,
55-
// so display shows actual messages (not rawCommand) to match what will be sent
56-
expect(queue.getDisplayText()).toBe("First message\nSummarize this conversation...");
52+
// Compaction requests cannot be mixed with other messages to prevent
53+
// silent failures where compaction metadata would be lost
54+
expect(() => queue.add("Summarize this conversation...", options)).toThrow(
55+
/Cannot queue compaction request/
56+
);
5757
});
5858

5959
it("should return joined messages when metadata type is not compaction-request", () => {
@@ -117,6 +117,48 @@ describe("MessageQueue", () => {
117117
});
118118
});
119119

120+
describe("hasCompactionRequest", () => {
121+
it("should return false for empty queue", () => {
122+
expect(queue.hasCompactionRequest()).toBe(false);
123+
});
124+
125+
it("should return false for normal messages", () => {
126+
queue.add("Regular message", { model: "gpt-4" });
127+
expect(queue.hasCompactionRequest()).toBe(false);
128+
});
129+
130+
it("should return true when compaction request is queued", () => {
131+
const metadata: MuxFrontendMetadata = {
132+
type: "compaction-request",
133+
rawCommand: "/compact",
134+
parsed: {},
135+
};
136+
137+
queue.add("Summarize...", {
138+
model: "claude-3-5-sonnet-20241022",
139+
muxMetadata: metadata,
140+
});
141+
142+
expect(queue.hasCompactionRequest()).toBe(true);
143+
});
144+
145+
it("should return false after clearing", () => {
146+
const metadata: MuxFrontendMetadata = {
147+
type: "compaction-request",
148+
rawCommand: "/compact",
149+
parsed: {},
150+
};
151+
152+
queue.add("Summarize...", {
153+
model: "claude-3-5-sonnet-20241022",
154+
muxMetadata: metadata,
155+
});
156+
queue.clear();
157+
158+
expect(queue.hasCompactionRequest()).toBe(false);
159+
});
160+
});
161+
120162
describe("multi-message batching", () => {
121163
it("should batch multiple follow-up messages", () => {
122164
queue.add("First message");
@@ -127,7 +169,7 @@ describe("MessageQueue", () => {
127169
expect(queue.getDisplayText()).toBe("First message\nSecond message\nThird message");
128170
});
129171

130-
it("should batch follow-up message after compaction", () => {
172+
it("should append follow-up to compaction's continueMessage", () => {
131173
const metadata: MuxFrontendMetadata = {
132174
type: "compaction-request",
133175
rawCommand: "/compact",
@@ -140,11 +182,44 @@ describe("MessageQueue", () => {
140182
});
141183
queue.add("And then do this follow-up task");
142184

143-
// When a follow-up is added, compaction metadata is lost (latestOptions overwritten),
144-
// so display shows actual messages to match what will be sent
145-
expect(queue.getDisplayText()).toBe("Summarize...\nAnd then do this follow-up task");
146-
// Raw messages have the actual prompt
185+
// Display shows compaction command plus follow-up
186+
expect(queue.getDisplayText()).toBe("/compact\nAnd then do this follow-up task");
187+
188+
// getMessages includes both the compaction prompt and follow-up
147189
expect(queue.getMessages()).toEqual(["Summarize...", "And then do this follow-up task"]);
190+
191+
// produceMessage merges follow-up into continueMessage
192+
const { message, options } = queue.produceMessage();
193+
expect(message).toBe("Summarize..."); // Just the compaction prompt
194+
const muxMeta = options?.muxMetadata as MuxFrontendMetadata;
195+
expect(muxMeta.type).toBe("compaction-request");
196+
if (muxMeta.type === "compaction-request") {
197+
expect(muxMeta.parsed.continueMessage?.text).toBe("And then do this follow-up task");
198+
}
199+
});
200+
201+
it("should merge follow-up with existing continueMessage", () => {
202+
const metadata: MuxFrontendMetadata = {
203+
type: "compaction-request",
204+
rawCommand: "/compact",
205+
parsed: {
206+
continueMessage: { text: "Original continue" },
207+
},
208+
};
209+
210+
queue.add("Summarize...", {
211+
model: "claude-3-5-sonnet-20241022",
212+
muxMetadata: metadata,
213+
});
214+
queue.add("Additional follow-up");
215+
216+
const { options } = queue.produceMessage();
217+
const muxMeta = options?.muxMetadata as MuxFrontendMetadata;
218+
if (muxMeta.type === "compaction-request") {
219+
expect(muxMeta.parsed.continueMessage?.text).toBe(
220+
"Original continue\nAdditional follow-up"
221+
);
222+
}
148223
});
149224

150225
it("should produce combined message for API call", () => {

src/node/services/messageQueue.ts

Lines changed: 129 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import type { ImagePart, SendMessageOptions } from "@/common/orpc/types";
2+
import type { CompactionRequestData, ContinueMessage } from "@/common/types/message";
23

3-
// Type guard for compaction request metadata (for display text)
4+
// Full compaction metadata structure for manipulation
45
interface CompactionMetadata {
56
type: "compaction-request";
67
rawCommand: string;
8+
parsed: CompactionRequestData;
79
}
810

911
function isCompactionMetadata(meta: unknown): meta is CompactionMetadata {
@@ -20,19 +22,42 @@ function isCompactionMetadata(meta: unknown): meta is CompactionMetadata {
2022
* - Latest options (model, thinking level, etc. - overwrites on each add)
2123
* - Image parts (accumulated across all messages)
2224
*
25+
* Special handling for compaction requests:
26+
* - Adding a compaction request to a non-empty queue throws an error
27+
* (must wait for current messages to send before compacting)
28+
* - Adding a message AFTER a compaction request appends it to the
29+
* compaction's continueMessage (so it's sent after compaction completes)
30+
*
2331
* Display logic:
24-
* - Single compaction request → shows rawCommand (/compact)
25-
* - Multiple messages → shows all actual message texts (since compaction metadata is lost anyway)
32+
* - Compaction request alone → shows rawCommand (/compact)
33+
* - Compaction with follow-ups → shows rawCommand + follow-up texts
34+
* - Regular messages → shows all message texts
2635
*/
2736
export class MessageQueue {
2837
private messages: string[] = [];
2938
private latestOptions?: SendMessageOptions;
3039
private accumulatedImages: ImagePart[] = [];
40+
// Track follow-up messages added after a compaction request
41+
// These get appended to the compaction's continueMessage
42+
private compactionFollowUps: string[] = [];
43+
private compactionFollowUpImages: ImagePart[] = [];
44+
45+
/**
46+
* Check if the queue currently contains a compaction request.
47+
*/
48+
hasCompactionRequest(): boolean {
49+
return isCompactionMetadata(this.latestOptions?.muxMetadata);
50+
}
3151

3252
/**
3353
* Add a message to the queue.
3454
* Updates to latest options, accumulates image parts.
3555
* Allows image-only messages (empty text with images).
56+
*
57+
* Special case: Adding a message after a compaction request appends it
58+
* to the compaction's continueMessage rather than overwriting options.
59+
*
60+
* @throws Error if trying to add a compaction request when queue already has messages
3661
*/
3762
add(message: string, options?: SendMessageOptions & { imageParts?: ImagePart[] }): void {
3863
const trimmedMessage = message.trim();
@@ -43,7 +68,32 @@ export class MessageQueue {
4368
return;
4469
}
4570

46-
// Add text message if non-empty
71+
const incomingIsCompaction = isCompactionMetadata(options?.muxMetadata);
72+
const queueHasCompaction = this.hasCompactionRequest();
73+
const queueHasMessages = !this.isEmpty();
74+
75+
// Cannot add compaction to a queue that already has messages
76+
// (user should wait for those messages to send first)
77+
if (incomingIsCompaction && queueHasMessages) {
78+
throw new Error(
79+
"Cannot queue compaction request: queue already has messages. " +
80+
"Wait for current stream to complete before compacting."
81+
);
82+
}
83+
84+
// Special case: adding follow-up message after a pending compaction
85+
// Append to the compaction's continueMessage instead of overwriting options
86+
if (!incomingIsCompaction && queueHasCompaction) {
87+
if (trimmedMessage.length > 0) {
88+
this.compactionFollowUps.push(trimmedMessage);
89+
}
90+
if (hasImages && options?.imageParts) {
91+
this.compactionFollowUpImages.push(...options.imageParts);
92+
}
93+
return;
94+
}
95+
96+
// Normal case: add message to queue
4797
if (trimmedMessage.length > 0) {
4898
this.messages.push(trimmedMessage);
4999
}
@@ -60,21 +110,29 @@ export class MessageQueue {
60110

61111
/**
62112
* Get all queued message texts (for editing/restoration).
113+
* Includes follow-up messages if compaction is pending.
63114
*/
64115
getMessages(): string[] {
116+
if (this.compactionFollowUps.length > 0) {
117+
return [...this.messages, ...this.compactionFollowUps];
118+
}
65119
return [...this.messages];
66120
}
67121

68122
/**
69123
* Get display text for queued messages.
70-
* - Single compaction request shows rawCommand (/compact)
71-
* - Multiple messages or non-compaction show actual message texts
124+
* - Compaction alone → shows rawCommand (/compact)
125+
* - Compaction with follow-ups → shows rawCommand + newline + follow-ups
126+
* - Regular messages → shows all message texts
72127
*/
73128
getDisplayText(): string {
74-
// Only show rawCommand for single compaction request
75-
// (compaction metadata is only preserved when no follow-up messages are added)
76129
const muxMetadata = this.latestOptions?.muxMetadata as unknown;
77-
if (this.messages.length === 1 && isCompactionMetadata(muxMetadata)) {
130+
131+
if (isCompactionMetadata(muxMetadata)) {
132+
// Show compaction command plus any follow-ups
133+
if (this.compactionFollowUps.length > 0) {
134+
return muxMetadata.rawCommand + "\n" + this.compactionFollowUps.join("\n");
135+
}
78136
return muxMetadata.rawCommand;
79137
}
80138

@@ -83,19 +141,71 @@ export class MessageQueue {
83141

84142
/**
85143
* Get accumulated image parts for display.
144+
* Includes follow-up images if compaction is pending.
86145
*/
87146
getImageParts(): ImagePart[] {
147+
if (this.compactionFollowUpImages.length > 0) {
148+
return [...this.accumulatedImages, ...this.compactionFollowUpImages];
149+
}
88150
return [...this.accumulatedImages];
89151
}
90152

91153
/**
92154
* Get combined message and options for sending.
93-
* Returns joined messages with latest options + accumulated images.
155+
*
156+
* For compaction requests with follow-ups:
157+
* - Returns the compaction message unchanged
158+
* - Merges follow-ups into the continueMessage field of the metadata
159+
*
160+
* For regular messages:
161+
* - Returns joined messages with latest options + accumulated images
94162
*/
95163
produceMessage(): {
96164
message: string;
97165
options?: SendMessageOptions & { imageParts?: ImagePart[] };
98166
} {
167+
// Cast from z.any() schema type to unknown for safe type narrowing
168+
const muxMetadata = this.latestOptions?.muxMetadata as unknown;
169+
170+
// Special handling for compaction with follow-ups
171+
if (isCompactionMetadata(muxMetadata) && this.hasFollowUps()) {
172+
const existingContinue = muxMetadata.parsed.continueMessage;
173+
const existingText = existingContinue?.text ?? "";
174+
const existingImages = existingContinue?.imageParts ?? [];
175+
176+
// Merge follow-ups with existing continueMessage
177+
const mergedText = existingText
178+
? existingText + "\n" + this.compactionFollowUps.join("\n")
179+
: this.compactionFollowUps.join("\n");
180+
const mergedImages = [...existingImages, ...this.compactionFollowUpImages];
181+
182+
const newContinueMessage: ContinueMessage = {
183+
text: mergedText,
184+
imageParts: mergedImages.length > 0 ? mergedImages : undefined,
185+
model: existingContinue?.model,
186+
};
187+
188+
// Create updated metadata with merged continueMessage
189+
const updatedMetadata: CompactionMetadata = {
190+
type: "compaction-request",
191+
rawCommand: muxMetadata.rawCommand,
192+
parsed: {
193+
...muxMetadata.parsed,
194+
continueMessage: newContinueMessage,
195+
},
196+
};
197+
198+
return {
199+
message: this.messages.join("\n"),
200+
options: {
201+
...this.latestOptions!,
202+
muxMetadata: updatedMetadata,
203+
imageParts: this.accumulatedImages.length > 0 ? this.accumulatedImages : undefined,
204+
},
205+
};
206+
}
207+
208+
// Normal case
99209
const joinedMessages = this.messages.join("\n");
100210

101211
const options = this.latestOptions
@@ -108,13 +218,22 @@ export class MessageQueue {
108218
return { message: joinedMessages, options };
109219
}
110220

221+
/**
222+
* Check if there are follow-up messages pending after a compaction.
223+
*/
224+
private hasFollowUps(): boolean {
225+
return this.compactionFollowUps.length > 0 || this.compactionFollowUpImages.length > 0;
226+
}
227+
111228
/**
112229
* Clear all queued messages, options, and images.
113230
*/
114231
clear(): void {
115232
this.messages = [];
116233
this.latestOptions = undefined;
117234
this.accumulatedImages = [];
235+
this.compactionFollowUps = [];
236+
this.compactionFollowUpImages = [];
118237
}
119238

120239
/**

0 commit comments

Comments
 (0)