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/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 1ce4f2d513..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"; @@ -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 @@ -796,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"; } /** 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);