Skip to content

Commit 8f176aa

Browse files
🤖 refactor: remove 'Accept early' feature from compactions (#748)
Remove Ctrl+A 'accept early' compaction feature which allowed accepting a partial compaction with a `[truncated]` sentinel. This simplifies the compaction flow. Closes #695, a hard to fix bug caused when accepting compactions early. I don't really see the value in the feature, and neither does Ammar. ## Changes - Remove `handleAbort()` method from CompactionHandler (was just returning false after previous simplification) - Remove `ACCEPT_EARLY_COMPACTION` keybind and its handler - Simplify stream-abort forwarding in agentSession - Update UI hints to remove 'accept early' references - Remove ~240 lines of tests for the removed functionality The `abandonPartial` flag is preserved for the Ctrl+C cancel flow (deletes partial message when cancelling a compaction). ## If you want a shorter compaction Edit the compaction command's tokens flag (e.g., `/compact tokens=2000`) to generate a new, shorter compaction instead. --- _Generated with `mux`_
1 parent 714677a commit 8f176aa

File tree

8 files changed

+9
-340
lines changed

8 files changed

+9
-340
lines changed

src/browser/components/AIView.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -501,11 +501,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
501501
? `${getModelName(currentModel)} streaming...`
502502
: "streaming..."
503503
}
504-
cancelText={
505-
isCompacting
506-
? `${formatKeybind(vimEnabled ? KEYBINDS.INTERRUPT_STREAM_VIM : KEYBINDS.INTERRUPT_STREAM_NORMAL)} cancel | ${formatKeybind(KEYBINDS.ACCEPT_EARLY_COMPACTION)} accept early`
507-
: `hit ${formatKeybind(vimEnabled ? KEYBINDS.INTERRUPT_STREAM_VIM : KEYBINDS.INTERRUPT_STREAM_NORMAL)} to cancel`
508-
}
504+
cancelText={`hit ${formatKeybind(vimEnabled ? KEYBINDS.INTERRUPT_STREAM_VIM : KEYBINDS.INTERRUPT_STREAM_NORMAL)} to cancel`}
509505
tokenCount={
510506
activeStreamMessageId
511507
? aggregator.getStreamingTokenCount(activeStreamMessageId)

src/browser/components/ChatInput/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -799,7 +799,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
799799
const interruptKeybind = vimEnabled
800800
? KEYBINDS.INTERRUPT_STREAM_VIM
801801
: KEYBINDS.INTERRUPT_STREAM_NORMAL;
802-
return `Compacting... (${formatKeybind(interruptKeybind)} cancel | ${formatKeybind(KEYBINDS.ACCEPT_EARLY_COMPACTION)} accept early | ${formatKeybind(KEYBINDS.SEND_MESSAGE)} to queue)`;
802+
return `Compacting... (${formatKeybind(interruptKeybind)} cancel | ${formatKeybind(KEYBINDS.SEND_MESSAGE)} to queue)`;
803803
}
804804

805805
// Build hints for normal input

src/browser/hooks/useAIViewKeybinds.ts

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ interface UseAIViewKeybindsParams {
3434
* - Ctrl+G: Jump to bottom
3535
* - Ctrl+T: Open terminal
3636
* - Ctrl+C (during compaction in vim mode): Cancel compaction, restore command
37-
* - Ctrl+A (during compaction): Accept early with [truncated]
3837
*
3938
* Note: In vim mode, Ctrl+C always interrupts streams. Use vim yank (y) commands for copying.
4039
*/
@@ -61,7 +60,6 @@ export function useAIViewKeybinds({
6160
: KEYBINDS.INTERRUPT_STREAM_NORMAL;
6261

6362
// Interrupt stream: Ctrl+C in vim mode, Esc in normal mode
64-
// (different from Ctrl+A which accepts early with [truncated])
6563
// Only intercept if actively compacting (otherwise allow browser default for copy in vim mode)
6664
if (matchesKeybind(e, interruptKeybind)) {
6765
if (canInterrupt && isCompactingStream(aggregator)) {
@@ -86,21 +84,6 @@ export function useAIViewKeybinds({
8684
}
8785
}
8886

89-
// Ctrl+A during compaction: accept early with [truncated] sentinel
90-
// (different from Ctrl+C which cancels and restores original state)
91-
// Only intercept if actively compacting (otherwise allow browser default for select all)
92-
if (matchesKeybind(e, KEYBINDS.ACCEPT_EARLY_COMPACTION)) {
93-
if (canInterrupt && isCompactingStream(aggregator)) {
94-
// Ctrl+A during compaction: perform compaction with partial summary
95-
// No flag set - handleCompactionAbort will perform compaction with [truncated]
96-
e.preventDefault();
97-
setAutoRetry(false);
98-
void window.api.workspace.interruptStream(workspaceId);
99-
}
100-
// Let browser handle Ctrl+A (select all) when not compacting
101-
return;
102-
}
103-
10487
// Focus chat input works anywhere (even in input fields)
10588
if (matchesKeybind(e, KEYBINDS.FOCUS_CHAT)) {
10689
e.preventDefault();

src/browser/utils/compaction/handler.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,8 @@
11
/**
22
* Compaction interrupt handling
33
*
4-
* Two interrupt flows during compaction:
5-
* - Ctrl+C (cancel): Abort compaction, restore original history + command to input
6-
* - Ctrl+A (accept early): Complete compaction with [truncated] sentinel
7-
*
8-
* Uses localStorage to persist cancellation intent across reloads:
9-
* - Before interrupt, store messageId in localStorage
10-
* - handleCompactionAbort checks localStorage and verifies messageId matches
11-
* - Reload-safe: localStorage persists, messageId ensures freshness
4+
* Ctrl+C (cancel): Abort compaction, enters edit mode on compaction-request message
5+
* with original /compact command restored for re-editing.
126
*/
137

148
import type { StreamingMessageAggregator } from "@/browser/utils/messages/StreamingMessageAggregator";

src/browser/utils/ui/keybinds.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -206,9 +206,6 @@ export const KEYBINDS = {
206206
INTERRUPT_STREAM_VIM: { key: "c", ctrl: true, macCtrlBehavior: "control" },
207207
INTERRUPT_STREAM_NORMAL: { key: "Escape" },
208208

209-
/** Accept partial compaction early (adds [truncated] sentinel) */
210-
ACCEPT_EARLY_COMPACTION: { key: "a", ctrl: true, macCtrlBehavior: "control" },
211-
212209
/** Focus chat input */
213210
FOCUS_INPUT_I: { key: "i" },
214211

src/node/services/agentSession.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,7 @@ import { Ok, Err } from "@/common/types/result";
2323
import { enforceThinkingPolicy } from "@/browser/utils/thinking/policy";
2424
import { createRuntime } from "@/node/runtime/runtimeFactory";
2525
import { MessageQueue } from "./messageQueue";
26-
27-
import type { StreamEndEvent, StreamAbortEvent } from "@/common/types/stream";
26+
import type { StreamEndEvent } from "@/common/types/stream";
2827
import { CompactionHandler } from "./compactionHandler";
2928

3029
export interface AgentSessionChatEvent {
@@ -475,11 +474,8 @@ export class AgentSession {
475474
this.sendQueuedMessages();
476475
});
477476

478-
forward("stream-abort", async (payload) => {
479-
const handled = await this.compactionHandler.handleAbort(payload as StreamAbortEvent);
480-
if (!handled) {
481-
this.emitChatEvent(payload);
482-
}
477+
forward("stream-abort", (payload) => {
478+
this.emitChatEvent(payload);
483479

484480
// Stream aborted: restore queued messages to input
485481
if (!this.messageQueue.isEmpty()) {

src/node/services/compactionHandler.test.ts

Lines changed: 1 addition & 237 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { CompactionHandler } from "./compactionHandler";
33
import type { HistoryService } from "./historyService";
44
import type { EventEmitter } from "events";
55
import { createMuxMessage, type MuxMessage } from "@/common/types/message";
6-
import type { StreamEndEvent, StreamAbortEvent } from "@/common/types/stream";
6+
import type { StreamEndEvent } from "@/common/types/stream";
77
import { Ok, Err, type Result } from "@/common/types/result";
88
import type { LanguageModelV2Usage } from "@ai-sdk/provider";
99

@@ -98,21 +98,6 @@ const createStreamEndEvent = (
9898
},
9999
});
100100

101-
const createStreamAbortEvent = (
102-
abandonPartial = false,
103-
metadata?: Record<string, unknown>
104-
): StreamAbortEvent => ({
105-
type: "stream-abort",
106-
workspaceId: "test-workspace",
107-
messageId: "msg-id",
108-
abandonPartial,
109-
metadata: {
110-
usage: { inputTokens: 100, outputTokens: 25, totalTokens: undefined },
111-
duration: 800,
112-
...metadata,
113-
},
114-
});
115-
116101
// DRY helper to set up successful compaction scenario
117102
const setupSuccessfulCompaction = (
118103
mockHistoryService: ReturnType<typeof createMockHistoryService>,
@@ -145,227 +130,6 @@ describe("CompactionHandler", () => {
145130
});
146131
});
147132

148-
describe("handleAbort() - Ctrl+C (cancel) Flow", () => {
149-
it("should return false when no compaction request found in history", async () => {
150-
const normalUserMsg = createMuxMessage("msg1", "user", "Hello", {
151-
historySequence: 0,
152-
muxMetadata: { type: "normal" },
153-
});
154-
mockHistoryService.mockGetHistory(Ok([normalUserMsg]));
155-
156-
const event = createStreamAbortEvent(false);
157-
const result = await handler.handleAbort(event);
158-
159-
expect(result).toBe(false);
160-
expect(emittedEvents).toHaveLength(0);
161-
});
162-
163-
it("should return false when abandonPartial=true (Ctrl+C cancel)", async () => {
164-
const compactionReq = createCompactionRequest();
165-
const assistantMsg = createAssistantMessage("Partial summary...");
166-
mockHistoryService.mockGetHistory(Ok([compactionReq, assistantMsg]));
167-
168-
const event = createStreamAbortEvent(true);
169-
const result = await handler.handleAbort(event);
170-
171-
expect(result).toBe(false);
172-
expect(mockHistoryService.clearHistory.mock.calls).toHaveLength(0);
173-
expect(emittedEvents).toHaveLength(0);
174-
});
175-
176-
it("should not perform compaction when cancelled", async () => {
177-
const compactionReq = createCompactionRequest();
178-
const assistantMsg = createAssistantMessage("Partial summary");
179-
mockHistoryService.mockGetHistory(Ok([compactionReq, assistantMsg]));
180-
181-
const event = createStreamAbortEvent(true);
182-
await handler.handleAbort(event);
183-
184-
expect(mockHistoryService.clearHistory.mock.calls).toHaveLength(0);
185-
expect(mockHistoryService.appendToHistory.mock.calls).toHaveLength(0);
186-
});
187-
188-
it("should not emit events when cancelled", async () => {
189-
const compactionReq = createCompactionRequest();
190-
const assistantMsg = createAssistantMessage("Partial");
191-
mockHistoryService.mockGetHistory(Ok([compactionReq, assistantMsg]));
192-
193-
const event = createStreamAbortEvent(true);
194-
await handler.handleAbort(event);
195-
196-
expect(emittedEvents).toHaveLength(0);
197-
});
198-
});
199-
200-
describe("handleAbort() - Ctrl+A (accept early) Flow", () => {
201-
it("should return false when last message is not assistant role", async () => {
202-
const compactionReq = createCompactionRequest();
203-
mockHistoryService.mockGetHistory(Ok([compactionReq]));
204-
205-
const event = createStreamAbortEvent(false);
206-
const result = await handler.handleAbort(event);
207-
208-
expect(result).toBe(false);
209-
});
210-
211-
it("should return true when successful", async () => {
212-
const compactionReq = createCompactionRequest();
213-
const assistantMsg = createAssistantMessage("Partial summary");
214-
setupSuccessfulCompaction(mockHistoryService, [compactionReq, assistantMsg]);
215-
216-
const event = createStreamAbortEvent(false);
217-
const result = await handler.handleAbort(event);
218-
219-
expect(result).toBe(true);
220-
});
221-
222-
it("should read partial summary from last assistant message in history", async () => {
223-
const compactionReq = createCompactionRequest();
224-
const assistantMsg = createAssistantMessage("Here is a partial summary");
225-
setupSuccessfulCompaction(mockHistoryService, [compactionReq, assistantMsg]);
226-
227-
const event = createStreamAbortEvent(false);
228-
await handler.handleAbort(event);
229-
230-
expect(mockHistoryService.appendToHistory.mock.calls).toHaveLength(1);
231-
const appendedMsg = mockHistoryService.appendToHistory.mock.calls[0][1] as MuxMessage;
232-
expect((appendedMsg.parts[0] as { type: "text"; text: string }).text).toContain(
233-
"Here is a partial summary"
234-
);
235-
});
236-
237-
it("should append [truncated] sentinel to partial summary", async () => {
238-
const compactionReq = createCompactionRequest();
239-
const assistantMsg = createAssistantMessage("Partial text");
240-
setupSuccessfulCompaction(mockHistoryService, [compactionReq, assistantMsg]);
241-
242-
const event = createStreamAbortEvent(false);
243-
await handler.handleAbort(event);
244-
245-
const appendedMsg = mockHistoryService.appendToHistory.mock.calls[0][1] as MuxMessage;
246-
expect((appendedMsg.parts[0] as { type: "text"; text: string }).text).toContain(
247-
"[truncated]"
248-
);
249-
});
250-
251-
it("should call clearHistory() and appendToHistory() with summary message", async () => {
252-
const compactionReq = createCompactionRequest();
253-
const assistantMsg = createAssistantMessage("Summary");
254-
setupSuccessfulCompaction(mockHistoryService, [compactionReq, assistantMsg]);
255-
256-
const event = createStreamAbortEvent(false);
257-
await handler.handleAbort(event);
258-
259-
expect(mockHistoryService.clearHistory.mock.calls).toHaveLength(1);
260-
expect(mockHistoryService.clearHistory.mock.calls[0][0]).toBe(workspaceId);
261-
expect(mockHistoryService.appendToHistory.mock.calls).toHaveLength(1);
262-
expect(mockHistoryService.appendToHistory.mock.calls[0][0]).toBe(workspaceId);
263-
const appendedMsg = mockHistoryService.appendToHistory.mock.calls[0][1] as MuxMessage;
264-
expect(appendedMsg.role).toBe("assistant");
265-
expect((appendedMsg.parts[0] as { type: "text"; text: string }).text).toContain(
266-
"[truncated]"
267-
);
268-
});
269-
270-
it("should emit delete event with cleared sequence numbers", async () => {
271-
const compactionReq = createCompactionRequest();
272-
const assistantMsg = createAssistantMessage("Summary");
273-
mockHistoryService.mockGetHistory(Ok([compactionReq, assistantMsg]));
274-
mockHistoryService.mockClearHistory(Ok([0, 1, 2]));
275-
mockHistoryService.mockAppendToHistory(Ok(undefined));
276-
277-
const event = createStreamAbortEvent(false);
278-
await handler.handleAbort(event);
279-
280-
const deleteEvent = emittedEvents.find(
281-
(_e) => (_e.data.message as { type?: string })?.type === "delete"
282-
);
283-
expect(deleteEvent).toBeDefined();
284-
expect(deleteEvent?.data).toEqual({
285-
workspaceId,
286-
message: {
287-
type: "delete",
288-
historySequences: [0, 1, 2],
289-
},
290-
});
291-
});
292-
293-
it("should emit summary message as assistant message", async () => {
294-
const compactionReq = createCompactionRequest();
295-
const assistantMsg = createAssistantMessage("Summary text");
296-
mockHistoryService.mockGetHistory(Ok([compactionReq, assistantMsg]));
297-
mockHistoryService.mockClearHistory(Ok([0, 1]));
298-
mockHistoryService.mockAppendToHistory(Ok(undefined));
299-
300-
const event = createStreamAbortEvent(false);
301-
await handler.handleAbort(event);
302-
303-
const summaryEvent = emittedEvents.find((_e) => {
304-
const msg = _e.data.message as MuxMessage | undefined;
305-
return msg?.role === "assistant" && msg?.parts !== undefined;
306-
});
307-
expect(summaryEvent).toBeDefined();
308-
expect(summaryEvent?.data.workspaceId).toBe(workspaceId);
309-
const summaryMsg = summaryEvent?.data.message as MuxMessage;
310-
expect((summaryMsg.parts[0] as { type: "text"; text: string }).text).toContain("[truncated]");
311-
});
312-
313-
it("should emit original stream-abort event to frontend", async () => {
314-
const compactionReq = createCompactionRequest();
315-
const assistantMsg = createAssistantMessage("Summary");
316-
mockHistoryService.mockGetHistory(Ok([compactionReq, assistantMsg]));
317-
mockHistoryService.mockClearHistory(Ok([0, 1]));
318-
mockHistoryService.mockAppendToHistory(Ok(undefined));
319-
320-
const event = createStreamAbortEvent(false, { duration: 999 });
321-
await handler.handleAbort(event);
322-
323-
const abortEvent = emittedEvents.find((_e) => _e.data.message === event);
324-
expect(abortEvent).toBeDefined();
325-
expect(abortEvent?.event).toBe("chat-event");
326-
expect(abortEvent?.data.workspaceId).toBe(workspaceId);
327-
const abortMsg = abortEvent?.data.message as StreamAbortEvent;
328-
expect(abortMsg.metadata?.duration).toBe(999);
329-
});
330-
331-
it("should preserve metadata (model, usage, duration, systemMessageTokens)", async () => {
332-
const compactionReq = createCompactionRequest();
333-
const usage = { inputTokens: 100, outputTokens: 25, totalTokens: 125 };
334-
const assistantMsg = createAssistantMessage("Summary", {
335-
usage,
336-
duration: 800,
337-
model: "claude-3-opus-20240229",
338-
});
339-
assistantMsg.metadata!.systemMessageTokens = 50;
340-
mockHistoryService.mockGetHistory(Ok([compactionReq, assistantMsg]));
341-
mockHistoryService.mockClearHistory(Ok([0, 1]));
342-
mockHistoryService.mockAppendToHistory(Ok(undefined));
343-
344-
const event = createStreamAbortEvent(false, { usage, duration: 800 });
345-
await handler.handleAbort(event);
346-
347-
const appendedMsg = mockHistoryService.appendToHistory.mock.calls[0][1] as MuxMessage;
348-
expect(appendedMsg.metadata?.model).toBe("claude-3-opus-20240229");
349-
expect(appendedMsg.metadata?.usage).toEqual(usage);
350-
expect(appendedMsg.metadata?.duration).toBe(800);
351-
expect(appendedMsg.metadata?.systemMessageTokens).toBe(50);
352-
});
353-
354-
it("should handle empty partial text gracefully (just [truncated])", async () => {
355-
const compactionReq = createCompactionRequest();
356-
const assistantMsg = createAssistantMessage("");
357-
mockHistoryService.mockGetHistory(Ok([compactionReq, assistantMsg]));
358-
mockHistoryService.mockClearHistory(Ok([0, 1]));
359-
mockHistoryService.mockAppendToHistory(Ok(undefined));
360-
361-
const event = createStreamAbortEvent(false);
362-
await handler.handleAbort(event);
363-
364-
const appendedMsg = mockHistoryService.appendToHistory.mock.calls[0][1] as MuxMessage;
365-
expect((appendedMsg.parts[0] as { type: "text"; text: string }).text).toBe("\n\n[truncated]");
366-
});
367-
});
368-
369133
describe("handleCompletion() - Normal Compaction Flow", () => {
370134
it("should return false when no compaction request found", async () => {
371135
const normalMsg = createMuxMessage("msg1", "user", "Hello", {

0 commit comments

Comments
 (0)