Skip to content

Commit 95bfca1

Browse files
authored
🤖 fix: stop compaction state leaking into continue streams (#2894)
## Summary Fix the streaming indicator so the follow-up continue turn after compaction is treated as a normal stream instead of incorrectly showing the compacting state. ## Background The browser-side stream aggregator was inferring `isCompacting` from the latest `/compact` user message whenever `stream-start.mode` was not explicitly `compact`. That fallback predates this PR (introduced in `ba125e654ae` / retained in `0b947aa6aa`), and it let the continue stream inherit stale compaction state even though the backend already emits the authoritative `stream-start.agentId` (`compact` for compaction, normal agent IDs for follow-up turns). The bug showed up recently because the newer activity-snapshot/catch-up path from `ba2b7b32911` can keep stale aggregator state visible for longer, but the root cause was the older compaction inference fallback. ## Implementation - prefer `stream-start.agentId == "compact"` as the primary compaction signal in `StreamingMessageAggregator.handleStreamStart` - only consult historical `/compact` request metadata for legacy stream-start events that do not include `agentId` - stop reusing an older compaction request once a compaction boundary summary has already landed - add regression coverage for both modern and legacy continue-stream cases ## Validation - `bun test src/browser/utils/messages/StreamingMessageAggregator.test.ts` - `make static-check` ## Risks Low. The change is narrowly scoped to compaction stream classification in the browser aggregator and preserves the existing fallback for older replayed stream-start events. --- _Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `xhigh` • Cost: `unknown`_ <!-- mux-attribution: model=openai:gpt-5.4 thinking=xhigh costs=unknown -->
1 parent c4f3841 commit 95bfca1

File tree

2 files changed

+109
-14
lines changed

2 files changed

+109
-14
lines changed

src/browser/utils/messages/StreamingMessageAggregator.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1831,6 +1831,83 @@ describe("StreamingMessageAggregator", () => {
18311831

18321832
expect(aggregator.isCompacting()).toBe(true);
18331833
});
1834+
test("does not treat non-compact agent streams as compacting even when the latest user message is /compact", () => {
1835+
const aggregator = new StreamingMessageAggregator(TEST_CREATED_AT);
1836+
1837+
const compactionRequestMessage = {
1838+
id: "msg1",
1839+
role: "user" as const,
1840+
parts: [{ type: "text" as const, text: "/compact" }],
1841+
metadata: {
1842+
historySequence: 1,
1843+
timestamp: Date.now(),
1844+
muxMetadata: {
1845+
type: "compaction-request" as const,
1846+
rawCommand: "/compact",
1847+
parsed: { model: "anthropic:claude-3-5-haiku-20241022" },
1848+
},
1849+
},
1850+
};
1851+
1852+
aggregator.loadHistoricalMessages([compactionRequestMessage], true);
1853+
1854+
aggregator.handleStreamStart({
1855+
type: "stream-start",
1856+
workspaceId: "test-workspace",
1857+
messageId: "continue-stream",
1858+
historySequence: 2,
1859+
model: "anthropic:claude-3-5-haiku-20241022",
1860+
startTime: Date.now(),
1861+
agentId: "exec",
1862+
mode: "exec",
1863+
});
1864+
1865+
expect(aggregator.isCompacting()).toBe(false);
1866+
});
1867+
1868+
test("does not reuse a completed compaction request when older stream-start events omit agentId", () => {
1869+
const aggregator = new StreamingMessageAggregator(TEST_CREATED_AT);
1870+
1871+
const compactionRequestMessage = {
1872+
id: "msg1",
1873+
role: "user" as const,
1874+
parts: [{ type: "text" as const, text: "/compact" }],
1875+
metadata: {
1876+
historySequence: 1,
1877+
timestamp: Date.now(),
1878+
muxMetadata: {
1879+
type: "compaction-request" as const,
1880+
rawCommand: "/compact",
1881+
parsed: { model: "anthropic:claude-3-5-haiku-20241022" },
1882+
},
1883+
},
1884+
};
1885+
const compactionSummaryMessage = createMuxMessage(
1886+
"summary-1",
1887+
"assistant",
1888+
"Compacted summary",
1889+
{
1890+
historySequence: 2,
1891+
timestamp: Date.now(),
1892+
compactionBoundary: true,
1893+
muxMetadata: { type: "compaction-summary" },
1894+
}
1895+
);
1896+
1897+
aggregator.loadHistoricalMessages([compactionRequestMessage, compactionSummaryMessage], true);
1898+
1899+
aggregator.handleStreamStart({
1900+
type: "stream-start",
1901+
workspaceId: "test-workspace",
1902+
messageId: "continue-stream",
1903+
historySequence: 3,
1904+
model: "anthropic:claude-3-5-haiku-20241022",
1905+
startTime: Date.now(),
1906+
mode: "exec",
1907+
});
1908+
1909+
expect(aggregator.isCompacting()).toBe(false);
1910+
});
18341911
});
18351912

18361913
describe("pending stream model", () => {

src/browser/utils/messages/StreamingMessageAggregator.ts

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1115,25 +1115,50 @@ export class StreamingMessageAggregator {
11151115
return this.pendingStreamModel;
11161116
}
11171117

1118-
private getLatestCompactionRequest(): CompactionRequestData | null {
1119-
if (this.pendingCompactionRequest) {
1120-
return this.pendingCompactionRequest;
1121-
}
1122-
1118+
private getLatestHistoricalCompactionRequest(): CompactionRequestData | null {
1119+
let sawCompletedCompaction = false;
11231120
const messages = this.getAllMessages();
11241121
for (let i = messages.length - 1; i >= 0; i--) {
11251122
const message = messages[i];
1123+
if (message.role === "assistant" && this.isCompactionBoundarySummaryMessage(message)) {
1124+
// A completed summary closes the earlier /compact request, so later auto-continue
1125+
// streams must not inherit a stale "compacting" UI state from that older turn.
1126+
sawCompletedCompaction = true;
1127+
continue;
1128+
}
11261129
if (message.role !== "user") continue;
11271130
const muxMetadata = message.metadata?.muxMetadata;
11281131
if (muxMetadata?.type === "compaction-request") {
1129-
return muxMetadata.parsed;
1132+
return sawCompletedCompaction ? null : muxMetadata.parsed;
11301133
}
11311134
return null;
11321135
}
11331136

11341137
return null;
11351138
}
11361139

1140+
private getLatestUnresolvedCompactionRequest(): CompactionRequestData | null {
1141+
return this.pendingCompactionRequest ?? this.getLatestHistoricalCompactionRequest();
1142+
}
1143+
1144+
private resolveStreamStartCompaction(data: StreamStartEvent): {
1145+
isCompacting: boolean;
1146+
hasCompactionContinue: boolean;
1147+
} {
1148+
// Keep stream classification separate from stream context construction so
1149+
// continue turns after /compact do not inherit stale UI state from history.
1150+
const streamSignalsCompaction = data.agentId === "compact" || data.mode === "compact";
1151+
if (!streamSignalsCompaction && data.agentId != null) {
1152+
return { isCompacting: false, hasCompactionContinue: false };
1153+
}
1154+
1155+
const compactionRequest = this.getLatestUnresolvedCompactionRequest();
1156+
return {
1157+
isCompacting: streamSignalsCompaction || compactionRequest !== null,
1158+
hasCompactionContinue: Boolean(compactionRequest?.followUpContent),
1159+
};
1160+
}
1161+
11371162
private setPendingStreamStartTime(time: number | null): void {
11381163
this.pendingStreamStartTime = time;
11391164
if (time === null) {
@@ -1454,14 +1479,7 @@ export class StreamingMessageAggregator {
14541479

14551480
// Unified event handlers that encapsulate all complex logic
14561481
handleStreamStart(data: StreamStartEvent): void {
1457-
// Detect compaction via stream mode (most authoritative).
1458-
// For backwards compat (older stream-start events without mode), fall back to the
1459-
// triggering compaction request metadata (pending or last user message).
1460-
const compactionRequest = this.getLatestCompactionRequest();
1461-
const isCompacting = data.mode === "compact" || compactionRequest !== null;
1462-
1463-
// Capture compaction-continue metadata before clearing pending request state.
1464-
const hasCompactionContinue = Boolean(compactionRequest?.followUpContent);
1482+
const { isCompacting, hasCompactionContinue } = this.resolveStreamStartCompaction(data);
14651483

14661484
// Clear pending stream start timestamp - stream has started
14671485
this.setPendingStreamStartTime(null);

0 commit comments

Comments
 (0)