Skip to content

Commit 9660f51

Browse files
authored
🤖 fix: prevent compaction infinite loop and stale usage display (#886)
Two related bugs were causing compaction to loop forever: ## Bug 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. ## Bug 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 b709eb3 commit 9660f51

File tree

3 files changed

+113
-21
lines changed

3 files changed

+113
-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: 61 additions & 10 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 preserve compaction metadata when follow-up is added", () => {
131173
const metadata: MuxFrontendMetadata = {
132174
type: "compaction-request",
133175
rawCommand: "/compact",
@@ -140,11 +182,20 @@ 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
185+
// Display shows all messages (multiple messages = not just compaction)
145186
expect(queue.getDisplayText()).toBe("Summarize...\nAnd then do this follow-up task");
146-
// Raw messages have the actual prompt
187+
188+
// getMessages includes both
147189
expect(queue.getMessages()).toEqual(["Summarize...", "And then do this follow-up task"]);
190+
191+
// produceMessage preserves compaction metadata from first message
192+
const { message, options } = queue.produceMessage();
193+
expect(message).toBe("Summarize...\nAnd then do this follow-up task");
194+
const muxMeta = options?.muxMetadata as MuxFrontendMetadata;
195+
expect(muxMeta.type).toBe("compaction-request");
196+
if (muxMeta.type === "compaction-request") {
197+
expect(muxMeta.rawCommand).toBe("/compact");
198+
}
148199
});
149200

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

src/node/services/messageQueue.ts

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,37 @@ function isCompactionMetadata(meta: unknown): meta is CompactionMetadata {
1717
*
1818
* Stores:
1919
* - Message texts (accumulated)
20-
* - Latest options (model, thinking level, etc. - overwrites on each add)
20+
* - First muxMetadata (preserved - never overwritten by subsequent adds)
21+
* - Latest options (model, etc. - updated on each add)
2122
* - Image parts (accumulated across all messages)
2223
*
24+
* IMPORTANT: muxMetadata from the first message is preserved even when
25+
* subsequent messages are added. This prevents compaction requests from
26+
* losing their metadata when follow-up messages are queued.
27+
*
2328
* Display logic:
2429
* - Single compaction request → shows rawCommand (/compact)
25-
* - Multiple messages → shows all actual message texts (since compaction metadata is lost anyway)
30+
* - Multiple messages → shows all actual message texts
2631
*/
2732
export class MessageQueue {
2833
private messages: string[] = [];
34+
private firstMuxMetadata?: unknown;
2935
private latestOptions?: SendMessageOptions;
3036
private accumulatedImages: ImagePart[] = [];
3137

38+
/**
39+
* Check if the queue currently contains a compaction request.
40+
*/
41+
hasCompactionRequest(): boolean {
42+
return isCompactionMetadata(this.firstMuxMetadata);
43+
}
44+
3245
/**
3346
* Add a message to the queue.
34-
* Updates to latest options, accumulates image parts.
35-
* Allows image-only messages (empty text with images).
47+
* Preserves muxMetadata from first message, updates other options.
48+
* Accumulates image parts.
49+
*
50+
* @throws Error if trying to add a compaction request when queue already has messages
3651
*/
3752
add(message: string, options?: SendMessageOptions & { imageParts?: ImagePart[] }): void {
3853
const trimmedMessage = message.trim();
@@ -43,13 +58,30 @@ export class MessageQueue {
4358
return;
4459
}
4560

61+
const incomingIsCompaction = isCompactionMetadata(options?.muxMetadata);
62+
const queueHasMessages = !this.isEmpty();
63+
64+
// Cannot add compaction to a queue that already has messages
65+
// (user should wait for those messages to send first)
66+
if (incomingIsCompaction && queueHasMessages) {
67+
throw new Error(
68+
"Cannot queue compaction request: queue already has messages. " +
69+
"Wait for current stream to complete before compacting."
70+
);
71+
}
72+
4673
// Add text message if non-empty
4774
if (trimmedMessage.length > 0) {
4875
this.messages.push(trimmedMessage);
4976
}
5077

5178
if (options) {
5279
const { imageParts, ...restOptions } = options;
80+
81+
// Preserve first muxMetadata (see class docblock for rationale)
82+
if (options.muxMetadata !== undefined && this.firstMuxMetadata === undefined) {
83+
this.firstMuxMetadata = options.muxMetadata;
84+
}
5385
this.latestOptions = restOptions;
5486

5587
if (imageParts && imageParts.length > 0) {
@@ -68,14 +100,12 @@ export class MessageQueue {
68100
/**
69101
* Get display text for queued messages.
70102
* - Single compaction request shows rawCommand (/compact)
71-
* - Multiple messages or non-compaction show actual message texts
103+
* - Multiple messages show all actual message texts
72104
*/
73105
getDisplayText(): string {
74106
// Only show rawCommand for single compaction request
75-
// (compaction metadata is only preserved when no follow-up messages are added)
76-
const muxMetadata = this.latestOptions?.muxMetadata as unknown;
77-
if (this.messages.length === 1 && isCompactionMetadata(muxMetadata)) {
78-
return muxMetadata.rawCommand;
107+
if (this.messages.length === 1 && isCompactionMetadata(this.firstMuxMetadata)) {
108+
return this.firstMuxMetadata.rawCommand;
79109
}
80110

81111
return this.messages.join("\n");
@@ -90,17 +120,21 @@ export class MessageQueue {
90120

91121
/**
92122
* Get combined message and options for sending.
93-
* Returns joined messages with latest options + accumulated images.
94123
*/
95124
produceMessage(): {
96125
message: string;
97126
options?: SendMessageOptions & { imageParts?: ImagePart[] };
98127
} {
99128
const joinedMessages = this.messages.join("\n");
100-
129+
// First metadata takes precedence (preserves compaction requests)
130+
const muxMetadata =
131+
this.firstMuxMetadata !== undefined
132+
? this.firstMuxMetadata
133+
: (this.latestOptions?.muxMetadata as unknown);
101134
const options = this.latestOptions
102135
? {
103136
...this.latestOptions,
137+
muxMetadata,
104138
imageParts: this.accumulatedImages.length > 0 ? this.accumulatedImages : undefined,
105139
}
106140
: undefined;
@@ -113,6 +147,7 @@ export class MessageQueue {
113147
*/
114148
clear(): void {
115149
this.messages = [];
150+
this.firstMuxMetadata = undefined;
116151
this.latestOptions = undefined;
117152
this.accumulatedImages = [];
118153
}

0 commit comments

Comments
 (0)