Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
38 changes: 31 additions & 7 deletions src/node/services/agentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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[]) => {
Expand Down
38 changes: 19 additions & 19 deletions src/node/services/compactionHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);
});

Expand Down Expand Up @@ -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]));
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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();
});
Expand All @@ -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();
});
});
Expand All @@ -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();
});
Expand All @@ -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 () => {
Expand All @@ -828,7 +828,7 @@ describe("CompactionHandler", () => {
);

const result = await handler.handleCompletion(event);
expect(result).toBe(true);
expect(result).toBe("success");
expect(mockHistoryService.clearHistory).toHaveBeenCalled();
});

Expand All @@ -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 () => {
Expand All @@ -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");
});
});
});
29 changes: 21 additions & 8 deletions src/node/services/compactionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -115,25 +123,30 @@ 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<boolean> {
async handleCompletion(event: StreamEndEvent): Promise<CompactionResult> {
// 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;
const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
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
Expand All @@ -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.
Expand All @@ -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)
Expand All @@ -182,7 +195,7 @@ export class CompactionHandler {
);
if (!result.success) {
log.error("Compaction failed:", result.error);
return false;
return "failed";
}

const durationSecs =
Expand All @@ -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";
}

/**
Expand Down
4 changes: 3 additions & 1 deletion tests/ui/compaction.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading