Skip to content
16 changes: 12 additions & 4 deletions src/browser/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ import { ConcurrentLocalWarning } from "./ConcurrentLocalWarning";
import { BackgroundProcessesBanner } from "./BackgroundProcessesBanner";
import { useBackgroundBashHandlers } from "@/browser/hooks/useBackgroundBashHandlers";
import { checkAutoCompaction } from "@/browser/utils/compaction/autoCompactionCheck";
import { executeCompaction } from "@/browser/utils/chatCommands";

import { useProviderOptions } from "@/browser/hooks/useProviderOptions";
import { useAutoCompactionSettings } from "../hooks/useAutoCompactionSettings";
import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions";
Expand Down Expand Up @@ -224,13 +224,21 @@ const AIViewInner: React.FC<AIViewProps> = ({
// We pass a default continueMessage of "Continue" as a resume sentinel so the backend can
// auto-send it after compaction. The compaction prompt builder special-cases this sentinel
// to avoid injecting it into the summarization request.
// Uses "force-compaction" source to distinguish from user-initiated /compact.
const handleForceCompaction = useCallback(() => {
if (!api) return;
void executeCompaction({
api,
// Use compactHistory endpoint with interrupt to ensure immediate compaction
void api.workspace.compactHistory({
workspaceId,
sendMessageOptions: pendingSendOptions,
source: "force-compaction",
interrupt: "abort",
continueMessage: { text: "Continue" },
sendMessageOptions: {
model: pendingSendOptions.model,
thinkingLevel: pendingSendOptions.thinkingLevel,
providerOptions: pendingSendOptions.providerOptions,
experiments: pendingSendOptions.experiments,
},
});
}, [api, workspaceId, pendingSendOptions]);

Expand Down
103 changes: 50 additions & 53 deletions src/browser/hooks/useIdleCompactionHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,68 +31,57 @@ void mock.module("@/browser/stores/WorkspaceStore", () => ({
void mock.module("@/browser/hooks/useSendMessageOptions", () => ({
buildSendMessageOptions: () => ({
model: "test-model",
gateway: "anthropic",
thinkingLevel: undefined,
providerOptions: undefined,
experiments: undefined,
}),
}));

// Mock executeCompaction - tracks calls and can be configured per test
let executeCompactionCalls: Array<{
api: unknown;
workspaceId: string;
sendMessageOptions: unknown;
source: string;
}> = [];
let executeCompactionResult: { success: true } | { success: false; error: string } = {
// Mock workspace.compactHistory - tracks calls and can be configured per test
let compactHistoryResolver: ((value: unknown) => void) | null = null;
let compactHistoryResult:
| { success: true; data: { operationId: string } }
| { success: false; error: unknown } = {
success: true,
data: { operationId: "op-1" },
};
let executeCompactionResolver:
| ((value: { success: true } | { success: false; error: string }) => void)
| null = null;

void mock.module("@/browser/utils/chatCommands", () => ({
executeCompaction: (opts: {
api: unknown;
workspaceId: string;
sendMessageOptions: unknown;
source: string;
}) => {
executeCompactionCalls.push(opts);
if (executeCompactionResolver) {
// Return a promise that hangs until manually resolved
return new Promise((resolve) => {
const savedResolver = executeCompactionResolver;
executeCompactionResolver = (val) => {
savedResolver?.(val);
resolve(val);
};
});
}
return Promise.resolve(executeCompactionResult);
},
}));

// Import after mocks are set up
import { useIdleCompactionHandler } from "./useIdleCompactionHandler";

describe("useIdleCompactionHandler", () => {
let mockApi: object;
let compactHistoryMock: ReturnType<typeof mock>;
let unsubscribeCalled: boolean;

beforeEach(() => {
// Set up DOM environment for React
globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis;
globalThis.document = globalThis.window.document;

mockApi = { workspace: { sendMessage: mock() } };
compactHistoryMock = mock((_args: unknown) => {
if (compactHistoryResolver) {
// Return a promise that hangs until manually resolved
return new Promise((resolve) => {
const savedResolver = compactHistoryResolver;
compactHistoryResolver = (val) => {
savedResolver?.(val);
resolve(val);
};
});
}
return Promise.resolve(compactHistoryResult);
});

mockApi = { workspace: { compactHistory: compactHistoryMock } };
unsubscribeCalled = false;
mockUnsubscribe = () => {
unsubscribeCalled = true;
};
capturedCallback = null;
onIdleCompactionNeededCallCount = 0;
executeCompactionCalls = [];
executeCompactionResult = { success: true };
executeCompactionResolver = null;
compactHistoryResult = { success: true, data: { operationId: "op-1" } };
compactHistoryResolver = null;
});

afterEach(() => {
Expand Down Expand Up @@ -122,28 +111,33 @@ describe("useIdleCompactionHandler", () => {
expect(onIdleCompactionNeededCallCount).toBe(0);
});

test("calls executeCompaction when event received", async () => {
test("calls workspace.compactHistory when event received", async () => {
renderHook(() => useIdleCompactionHandler({ api: mockApi as never }));

expect(capturedCallback).not.toBeNull();
capturedCallback!("workspace-123");

// Wait for async execution
await Promise.resolve();
await Promise.resolve(); // Extra tick for .then()

expect(executeCompactionCalls).toHaveLength(1);
expect(executeCompactionCalls[0]).toEqual({
api: mockApi,
expect(compactHistoryMock.mock.calls).toHaveLength(1);
expect(compactHistoryMock.mock.calls[0][0]).toEqual({
workspaceId: "workspace-123",
sendMessageOptions: { model: "test-model", gateway: "anthropic" },
source: "idle-compaction",
sendMessageOptions: {
model: "test-model",
thinkingLevel: undefined,
providerOptions: undefined,
experiments: undefined,
},
});
});

test("prevents duplicate triggers for same workspace while in-flight", async () => {
// Make executeCompaction hang until we resolve it - this no-op will be replaced when promise is created
// Make compactHistory hang until we resolve it - this no-op will be replaced when promise is created
// eslint-disable-next-line @typescript-eslint/no-empty-function
executeCompactionResolver = () => {};
compactHistoryResolver = () => {};

renderHook(() => useIdleCompactionHandler({ api: mockApi as never }));

Expand All @@ -156,11 +150,12 @@ describe("useIdleCompactionHandler", () => {
await Promise.resolve();

// Should only have called once
expect(executeCompactionCalls).toHaveLength(1);
expect(compactHistoryMock.mock.calls).toHaveLength(1);

// Resolve the first compaction
executeCompactionResolver({ success: true });
compactHistoryResolver({ success: true, data: { operationId: "op-1" } });
await Promise.resolve();
await Promise.resolve(); // Extra tick for .finally()
});

test("allows different workspaces to compact simultaneously", async () => {
Expand All @@ -170,7 +165,7 @@ describe("useIdleCompactionHandler", () => {
capturedCallback!("workspace-2");
await Promise.resolve();

expect(executeCompactionCalls).toHaveLength(2);
expect(compactHistoryMock.mock.calls).toHaveLength(2);
});

test("clears workspace from triggered set after success", async () => {
Expand All @@ -181,18 +176,19 @@ describe("useIdleCompactionHandler", () => {
await Promise.resolve();
await Promise.resolve(); // Extra tick for .then()

expect(executeCompactionCalls).toHaveLength(1);
expect(compactHistoryMock.mock.calls).toHaveLength(1);
await Promise.resolve(); // Extra tick for .finally()

// Should be able to trigger again after completion
capturedCallback!("workspace-123");
await Promise.resolve();

expect(executeCompactionCalls).toHaveLength(2);
expect(compactHistoryMock.mock.calls).toHaveLength(2);
});

test("clears workspace from triggered set after failure", async () => {
// Make first call fail
executeCompactionResult = { success: false, error: "test error" };
compactHistoryResult = { success: false, error: "test error" };

// Suppress console.error for this test
const originalError = console.error;
Expand All @@ -205,13 +201,14 @@ describe("useIdleCompactionHandler", () => {
await Promise.resolve();
await Promise.resolve(); // Extra tick for .then()

expect(executeCompactionCalls).toHaveLength(1);
expect(compactHistoryMock.mock.calls).toHaveLength(1);
await Promise.resolve(); // Extra tick for .finally()

// Should be able to trigger again after failure
capturedCallback!("workspace-123");
await Promise.resolve();

expect(executeCompactionCalls).toHaveLength(2);
expect(compactHistoryMock.mock.calls).toHaveLength(2);

console.error = originalError;
});
Expand Down
52 changes: 29 additions & 23 deletions src/browser/hooks/useIdleCompactionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,13 @@
* The backend's IdleCompactionService detects when workspaces have been idle
* for a configured period and emits `idle-compaction-needed` events to the stream.
*
* This hook listens for these signals and triggers compaction via the frontend's
* executeCompaction(), which handles gateway, model preferences, etc.
*
* Status display is handled data-driven: the compaction request message includes
* displayStatus metadata, which the aggregator reads to set sidebar status.
* Status is cleared when the summary message with compacted: "idle" arrives.
* This hook listens for these signals and triggers compaction via the control-plane
* compactHistory endpoint, which ensures the compaction cannot be dropped or queued.
*/

import { useEffect, useRef } from "react";
import type { RouterClient } from "@orpc/server";
import type { AppRouter } from "@/node/orpc/router";
import { executeCompaction } from "@/browser/utils/chatCommands";
import { buildSendMessageOptions } from "@/browser/hooks/useSendMessageOptions";
import { workspaceStore } from "@/browser/stores/WorkspaceStore";

Expand Down Expand Up @@ -47,22 +42,33 @@ export function useIdleCompactionHandler(params: IdleCompactionHandlerParams): v
// Use buildSendMessageOptions to get correct model, gateway, thinking level, etc.
const sendMessageOptions = buildSendMessageOptions(workspaceId);

// Status is handled data-driven via displayStatus in the message metadata
void executeCompaction({
api,
workspaceId,
sendMessageOptions,
source: "idle-compaction",
}).then((result) => {
if (!result.success) {
console.error("Idle compaction failed:", result.error);
}
// Always clear from triggered set after completion (success or failure).
// This allows the workspace to be re-triggered on subsequent hourly checks
// if it becomes idle again. Backend eligibility checks (already_compacted,
// currently_streaming) provide authoritative deduplication.
triggeredWorkspacesRef.current.delete(workspaceId);
});
// Use control-plane compactHistory endpoint for reliability
void api.workspace
.compactHistory({
workspaceId,
source: "idle-compaction",
sendMessageOptions: {
model: sendMessageOptions.model,
thinkingLevel: sendMessageOptions.thinkingLevel,
providerOptions: sendMessageOptions.providerOptions,
experiments: sendMessageOptions.experiments,
},
})
.then((result) => {
if (!result.success) {
console.error("Idle compaction failed:", result.error);
}
})
.catch((error) => {
console.error("Idle compaction error:", error);
})
.finally(() => {
// Always clear from triggered set after completion (success or failure).
// This allows the workspace to be re-triggered on subsequent hourly checks
// if it becomes idle again. Backend eligibility checks (already_compacted,
// currently_streaming) provide authoritative deduplication.
triggeredWorkspacesRef.current.delete(workspaceId);
});
};

const unsubscribe = workspaceStore.onIdleCompactionNeeded(handleIdleCompactionNeeded);
Expand Down
41 changes: 33 additions & 8 deletions src/browser/utils/chatCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -690,20 +690,45 @@ export function prepareCompactionMessage(options: CompactionOptions): {
}

/**
* Execute a compaction command
* Execute a compaction command via the control-plane endpoint.
* This ensures compaction cannot be dropped or treated as a normal message.
*/
export async function executeCompaction(
options: CompactionOptions & { api: RouterClient<AppRouter> }
): Promise<CompactionResult> {
const { messageText, metadata, sendOptions } = prepareCompactionMessage(options);
// Resolve compaction model preference
const effectiveModel = resolveCompactionModel(options.model);

// Map source to control-plane format
const source: "user" | "force-compaction" | "idle-compaction" =
options.source === "idle-compaction" ? "idle-compaction" : "user";

// Build continue message if provided
const continueMode = options.continueMessage?.mode ?? "exec";
const continueMessage = options.continueMessage
? {
text: options.continueMessage.text,
imageParts: options.continueMessage.imageParts,
model: options.continueMessage.model ?? options.sendMessageOptions.model,
mode: continueMode,
}
: undefined;

const result = await options.api.workspace.sendMessage({
// Call the control-plane compactHistory endpoint
const result = await options.api.workspace.compactHistory({
workspaceId: options.workspaceId,
message: messageText,
options: {
...sendOptions,
muxMetadata: metadata,
editMessageId: options.editMessageId,
model: effectiveModel,
maxOutputTokens: options.maxOutputTokens,
continueMessage,
source,
// For edits, interrupt any active stream before compacting.
// We preserve partial output; compaction will commit partial.json before summarizing.
interrupt: options.editMessageId ? "abort" : undefined,
sendMessageOptions: {
model: options.sendMessageOptions.model,
thinkingLevel: options.sendMessageOptions.thinkingLevel,
providerOptions: options.sendMessageOptions.providerOptions,
experiments: options.sendMessageOptions.experiments,
},
});

Expand Down
Loading