From 870c88f498954d6c9fa20141f455c5700dd92bcc Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 15 Jan 2026 12:37:22 -0600 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=A4=96=20fix:=20add=20context=20wrapp?= =?UTF-8?q?er=20to=20post-compaction=20follow-up=20message?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After compaction completes, the queued follow-up message was sent as raw text without any context. This made it unclear to the model that this message triggered compaction and that relevant context is in the summary. The fix wraps non-default follow-up messages with a context note: "(This was the message that triggered compaction - context is in the summary above)" The default resume sentinel ("Continue") is excluded since it's just a signal to continue, not meaningful user content. This is NOT a regression - the original implementation (dacc278f, Nov 2025) always sent follow-ups as raw text. This change improves upon that design. --- src/node/services/agentSession.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 1ce4f2d513..babb6729c7 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -595,7 +595,20 @@ export class AgentSession { const continueMessage = typedMuxMetadata.parsed.continueMessage; // Process the continue message content (handles reviews -> text formatting + metadata) - const { finalText, metadata } = prepareUserMessageForSend(continueMessage); + const prepared = prepareUserMessageForSend(continueMessage); + const { metadata } = prepared; + let { finalText } = prepared; + + // Wrap non-default follow-up with context so the model knows this was the message + // that triggered compaction. The "Continue" sentinel is excluded since it's just + // a resume signal, not meaningful user content. + const isDefaultResume = + continueMessage.text?.trim() === "Continue" && + !continueMessage.imageParts?.length && + !continueMessage.reviews?.length; + if (!isDefaultResume && finalText.trim().length > 0) { + finalText = `(This was the message that triggered compaction - context is in the summary above)\n\n${finalText}`; + } // Legacy compatibility: older clients stored `continueMessage.mode` (exec/plan) and compaction // requests run with agentId="compact". Avoid falling back to the compact agent for the From 240daabea88baf241398d56c4496636f2a069234 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 15 Jan 2026 12:57:49 -0600 Subject: [PATCH 2/3] fix: update tests for new compaction follow-up context wrapper --- src/node/services/agentSession.continueMessageAgentId.test.ts | 4 +++- tests/ui/compaction.integration.test.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/node/services/agentSession.continueMessageAgentId.test.ts b/src/node/services/agentSession.continueMessageAgentId.test.ts index bc8acba9ad..2d0eddc204 100644 --- a/src/node/services/agentSession.continueMessageAgentId.test.ts +++ b/src/node/services/agentSession.continueMessageAgentId.test.ts @@ -106,7 +106,9 @@ describe("AgentSession continue-message agentId fallback", () => { expect(result.success).toBe(true); const queued = internals.messageQueue.produceMessage(); - expect(queued.message).toBe("follow up"); + // Message is wrapped with context explaining it triggered compaction + expect(queued.message).toContain("follow up"); + expect(queued.message).toContain("triggered compaction"); expect(queued.options?.agentId).toBe("plan"); session.dispose(); diff --git a/tests/ui/compaction.integration.test.ts b/tests/ui/compaction.integration.test.ts index ed43e7bacf..0134e3c63f 100644 --- a/tests/ui/compaction.integration.test.ts +++ b/tests/ui/compaction.integration.test.ts @@ -65,7 +65,9 @@ describe("Compaction UI (mock AI router)", () => { await app.chat.send(`/compact -t 500\n${continueText}`); await app.chat.expectTranscriptContains("Mock compaction summary:"); - await app.chat.expectTranscriptContains(`Mock response: ${continueText}`); + // Continue message is wrapped with context explaining it triggered compaction + await app.chat.expectTranscriptContains(`Mock response:`); + await app.chat.expectTranscriptContains(continueText); // Compaction should replace all previous history. await app.chat.expectTranscriptNotContains(seedMessage); From ba51dd868d75f65ebf26366e1afc39ea120224f0 Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 16 Jan 2026 10:35:11 -0600 Subject: [PATCH 3/3] fix: don't send queued messages when compaction fails When compaction fails (e.g., Gemini 'completing' without producing a valid summary), the queued follow-up message was still being sent. This caused the follow-up to go into the non-compacted chat, potentially hitting context limits again. The fix introduces a CompactionResult discriminated union: - 'not-compaction': Normal stream end, not a compaction request - 'success': Compaction completed successfully - 'failed': Compaction was attempted but failed Queued messages are now only sent when result is not 'failed', preventing the follow-up from being injected into a non-compacted chat. --- src/node/services/agentSession.ts | 23 +++++++++---- src/node/services/compactionHandler.test.ts | 38 ++++++++++----------- src/node/services/compactionHandler.ts | 29 +++++++++++----- 3 files changed, 57 insertions(+), 33 deletions(-) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index babb6729c7..82af6fe613 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -35,7 +35,7 @@ import { import { createRuntime } from "@/node/runtime/runtimeFactory"; import { MessageQueue } from "./messageQueue"; import type { StreamEndEvent } from "@/common/types/stream"; -import { CompactionHandler } from "./compactionHandler"; +import { CompactionHandler, type CompactionResult } from "./compactionHandler"; import type { TelemetryService } from "./telemetryService"; import type { BackgroundProcessManager } from "./backgroundProcessManager"; import { computeDiff } from "@/node/utils/diff"; @@ -809,16 +809,27 @@ export class AgentSession { forward("runtime-status", (payload) => this.emitChatEvent(payload)); forward("stream-end", async (payload) => { - const handled = await this.compactionHandler.handleCompletion(payload as StreamEndEvent); - if (!handled) { + const compactionResult: CompactionResult = await this.compactionHandler.handleCompletion( + payload as StreamEndEvent + ); + if (compactionResult === "not-compaction") { this.emitChatEvent(payload); - } else { + } else if (compactionResult === "success") { // Compaction completed - notify to trigger metadata refresh // This allows the frontend to get updated postCompaction state this.onCompactionComplete?.(); } - // Stream end: auto-send queued messages - this.sendQueuedMessages(); + // else: compactionResult === "failed" - compaction was attempted but failed + // (model didn't produce valid summary, e.g., Gemini "completing" without compacting) + + // Only send queued messages if: + // 1. This wasn't a compaction stream (normal message flow) + // 2. Compaction succeeded (safe to continue with follow-up) + // Do NOT send if compaction failed - the queued message would go into + // the non-compacted chat, potentially hitting context limits again. + if (compactionResult !== "failed") { + this.sendQueuedMessages(); + } }); const errorHandler = (...args: unknown[]) => { diff --git a/src/node/services/compactionHandler.test.ts b/src/node/services/compactionHandler.test.ts index b0a43a97b7..18facfd45c 100644 --- a/src/node/services/compactionHandler.test.ts +++ b/src/node/services/compactionHandler.test.ts @@ -157,17 +157,17 @@ describe("CompactionHandler", () => { const event = createStreamEndEvent("Summary"); const result = await handler.handleCompletion(event); - expect(result).toBe(false); + expect(result).toBe("not-compaction"); expect(mockHistoryService.clearHistory.mock.calls).toHaveLength(0); }); - it("should return false when historyService fails", async () => { + it("should return not-compaction when historyService fails", async () => { mockHistoryService.mockGetHistory(Err("Database error")); const event = createStreamEndEvent("Summary"); const result = await handler.handleCompletion(event); - expect(result).toBe(false); + expect(result).toBe("not-compaction"); }); it("should capture compaction_completed telemetry on successful compaction", async () => { @@ -210,7 +210,7 @@ describe("CompactionHandler", () => { const event = createStreamEndEvent("Complete summary"); const result = await handler.handleCompletion(event); - expect(result).toBe(true); + expect(result).toBe("success"); }); it("should join multiple text parts from event.parts", async () => { @@ -394,8 +394,8 @@ describe("CompactionHandler", () => { const result1 = await handler.handleCompletion(event); const result2 = await handler.handleCompletion(event); - expect(result1).toBe(true); - expect(result2).toBe(true); + expect(result1).toBe("success"); + expect(result2).toBe("success"); expect(mockHistoryService.clearHistory.mock.calls).toHaveLength(1); }); @@ -439,11 +439,11 @@ describe("CompactionHandler", () => { const event = createStreamEndEvent("Summary"); const result = await handler.handleCompletion(event); - expect(result).toBe(false); + expect(result).toBe("failed"); expect(mockHistoryService.appendToHistory.mock.calls).toHaveLength(0); }); - it("should return false when appendToHistory() fails", async () => { + it("should return failed when appendToHistory() fails", async () => { const compactionReq = createCompactionRequest(); mockHistoryService.mockGetHistory(Ok([compactionReq])); mockHistoryService.mockClearHistory(Ok([0])); @@ -452,7 +452,7 @@ describe("CompactionHandler", () => { const event = createStreamEndEvent("Summary"); const result = await handler.handleCompletion(event); - expect(result).toBe(false); + expect(result).toBe("failed"); }); it("should log errors but not throw", async () => { @@ -464,7 +464,7 @@ describe("CompactionHandler", () => { // Should not throw const result = await handler.handleCompletion(event); - expect(result).toBe(false); + expect(result).toBe("failed"); }); it("should not emit events when compaction fails mid-process", async () => { @@ -743,8 +743,8 @@ describe("CompactionHandler", () => { const result = await handler.handleCompletion(event); - // Should return false and NOT perform compaction - expect(result).toBe(false); + // Should return "failed" and NOT perform compaction + expect(result).toBe("failed"); expect(mockHistoryService.clearHistory).not.toHaveBeenCalled(); expect(mockHistoryService.appendToHistory).not.toHaveBeenCalled(); }); @@ -762,7 +762,7 @@ describe("CompactionHandler", () => { const result = await handler.handleCompletion(event); - expect(result).toBe(false); + expect(result).toBe("failed"); expect(mockHistoryService.clearHistory).not.toHaveBeenCalled(); }); }); @@ -787,8 +787,8 @@ describe("CompactionHandler", () => { const result = await handler.handleCompletion(event); - // Should return false and NOT perform compaction - expect(result).toBe(false); + // Should return "failed" and NOT perform compaction + expect(result).toBe("failed"); expect(mockHistoryService.clearHistory).not.toHaveBeenCalled(); expect(mockHistoryService.appendToHistory).not.toHaveBeenCalled(); }); @@ -809,7 +809,7 @@ describe("CompactionHandler", () => { const event = createStreamEndEvent(arbitraryJson); const result = await handler.handleCompletion(event); - expect(result).toBe(false); + expect(result).toBe("failed"); }); it("should accept valid compaction summary text", async () => { @@ -828,7 +828,7 @@ describe("CompactionHandler", () => { ); const result = await handler.handleCompletion(event); - expect(result).toBe(true); + expect(result).toBe("success"); expect(mockHistoryService.clearHistory).toHaveBeenCalled(); }); @@ -848,7 +848,7 @@ describe("CompactionHandler", () => { ); const result = await handler.handleCompletion(event); - expect(result).toBe(true); + expect(result).toBe("success"); }); it("should not reject JSON arrays (only objects)", async () => { @@ -865,7 +865,7 @@ describe("CompactionHandler", () => { const event = createStreamEndEvent('["item1", "item2"]'); const result = await handler.handleCompletion(event); - expect(result).toBe(true); + expect(result).toBe("success"); }); }); }); diff --git a/src/node/services/compactionHandler.ts b/src/node/services/compactionHandler.ts index 3d621eb016..8d2afbb626 100644 --- a/src/node/services/compactionHandler.ts +++ b/src/node/services/compactionHandler.ts @@ -43,6 +43,14 @@ function looksLikeRawJsonObject(text: string): boolean { import { computeRecencyFromMessages } from "@/common/utils/recency"; +/** + * Result of handleCompletion: + * - "not-compaction": Normal stream end, not a compaction request - proceed normally + * - "success": Compaction completed successfully - proceed with queued messages + * - "failed": Compaction was attempted but failed - do NOT send queued messages + */ +export type CompactionResult = "not-compaction" | "success" | "failed"; + interface CompactionHandlerOptions { workspaceId: string; historyService: HistoryService; @@ -115,12 +123,17 @@ export class CompactionHandler { * * Detects when a compaction stream finishes, extracts the summary, * and performs history replacement atomically. + * + * @returns CompactionResult indicating what happened: + * - "not-compaction": Not a compaction stream, proceed normally + * - "success": Compaction completed successfully + * - "failed": Compaction was attempted but failed (model didn't produce valid summary) */ - async handleCompletion(event: StreamEndEvent): Promise { + async handleCompletion(event: StreamEndEvent): Promise { // Check if the last user message is a compaction-request const historyResult = await this.historyService.getHistory(this.workspaceId); if (!historyResult.success) { - return false; + return "not-compaction"; } const messages = historyResult.data; @@ -128,12 +141,12 @@ export class CompactionHandler { const isCompaction = lastUserMsg?.metadata?.muxMetadata?.type === "compaction-request"; if (!isCompaction || !lastUserMsg) { - return false; + return "not-compaction"; } // Dedupe: If we've already processed this compaction-request, skip if (this.processedCompactionRequestIds.has(lastUserMsg.id)) { - return true; + return "success"; } const summary = event.parts @@ -148,7 +161,7 @@ export class CompactionHandler { partsCount: event.parts.length, }); // Don't mark as processed so user can retry - return false; + return "failed"; } // Self-healing: Reject compaction if summary is just a raw JSON object. @@ -163,7 +176,7 @@ export class CompactionHandler { } ); // Don't mark as processed so user can retry - return false; + return "failed"; } // Check if this was an idle-compaction (auto-triggered due to inactivity) @@ -182,7 +195,7 @@ export class CompactionHandler { ); if (!result.success) { log.error("Compaction failed:", result.error); - return false; + return "failed"; } const durationSecs = @@ -208,7 +221,7 @@ export class CompactionHandler { // Emit stream-end to frontend so UI knows compaction is complete this.emitChatEvent(event); - return true; + return "success"; } /**