Skip to content

Commit 70b029f

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 follow-up messages were queued after a compaction request, the queue's latestOptions got overwritten, losing the muxMetadata that marks the message as a compaction-request. The compaction would then be sent as a normal message, never triggering history replacement. Fix: Preserve the first muxMetadata and use it in produceMessage(). This ensures compaction metadata survives even when follow-ups are queued afterward. 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 70b029f

File tree

3 files changed

+122
-20
lines changed

3 files changed

+122
-20
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: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,39 @@ 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+
// muxMetadata from first message (preserved, never overwritten)
35+
private firstMuxMetadata?: unknown;
36+
// Latest options (for model, etc.)
2937
private latestOptions?: SendMessageOptions;
3038
private accumulatedImages: ImagePart[] = [];
3139

40+
/**
41+
* Check if the queue currently contains a compaction request.
42+
*/
43+
hasCompactionRequest(): boolean {
44+
return isCompactionMetadata(this.firstMuxMetadata);
45+
}
46+
3247
/**
3348
* Add a message to the queue.
34-
* Updates to latest options, accumulates image parts.
35-
* Allows image-only messages (empty text with images).
49+
* Preserves muxMetadata from first message, updates other options.
50+
* Accumulates image parts.
51+
*
52+
* @throws Error if trying to add a compaction request when queue already has messages
3653
*/
3754
add(message: string, options?: SendMessageOptions & { imageParts?: ImagePart[] }): void {
3855
const trimmedMessage = message.trim();
@@ -43,13 +60,33 @@ export class MessageQueue {
4360
return;
4461
}
4562

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

5180
if (options) {
5281
const { imageParts, ...restOptions } = options;
82+
83+
// Preserve first muxMetadata (critical for compaction)
84+
// This ensures compaction metadata isn't lost when follow-ups are added
85+
if (options.muxMetadata !== undefined && this.firstMuxMetadata === undefined) {
86+
this.firstMuxMetadata = options.muxMetadata;
87+
}
88+
89+
// Always update latest options (for model changes, etc.)
5390
this.latestOptions = restOptions;
5491

5592
if (imageParts && imageParts.length > 0) {
@@ -68,14 +105,12 @@ export class MessageQueue {
68105
/**
69106
* Get display text for queued messages.
70107
* - Single compaction request shows rawCommand (/compact)
71-
* - Multiple messages or non-compaction show actual message texts
108+
* - Multiple messages show all actual message texts
72109
*/
73110
getDisplayText(): string {
74111
// 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;
112+
if (this.messages.length === 1 && isCompactionMetadata(this.firstMuxMetadata)) {
113+
return this.firstMuxMetadata.rawCommand;
79114
}
80115

81116
return this.messages.join("\n");
@@ -90,17 +125,26 @@ export class MessageQueue {
90125

91126
/**
92127
* Get combined message and options for sending.
93-
* Returns joined messages with latest options + accumulated images.
128+
* Returns joined messages with options (using preserved muxMetadata).
94129
*/
95130
produceMessage(): {
96131
message: string;
97132
options?: SendMessageOptions & { imageParts?: ImagePart[] };
98133
} {
99134
const joinedMessages = this.messages.join("\n");
100135

136+
// Merge latest options with preserved muxMetadata
137+
// Use preserved first muxMetadata if available (critical for compaction)
138+
// Falls back to latest options' muxMetadata if no first was stored
139+
const effectiveMuxMetadata =
140+
this.firstMuxMetadata !== undefined
141+
? this.firstMuxMetadata
142+
: (this.latestOptions?.muxMetadata as unknown);
143+
101144
const options = this.latestOptions
102145
? {
103146
...this.latestOptions,
147+
muxMetadata: effectiveMuxMetadata,
104148
imageParts: this.accumulatedImages.length > 0 ? this.accumulatedImages : undefined,
105149
}
106150
: undefined;
@@ -113,6 +157,7 @@ export class MessageQueue {
113157
*/
114158
clear(): void {
115159
this.messages = [];
160+
this.firstMuxMetadata = undefined;
116161
this.latestOptions = undefined;
117162
this.accumulatedImages = [];
118163
}

0 commit comments

Comments
 (0)