Skip to content
Merged
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
6 changes: 1 addition & 5 deletions src/browser/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -501,11 +501,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
? `${getModelName(currentModel)} streaming...`
: "streaming..."
}
cancelText={
isCompacting
? `${formatKeybind(vimEnabled ? KEYBINDS.INTERRUPT_STREAM_VIM : KEYBINDS.INTERRUPT_STREAM_NORMAL)} cancel | ${formatKeybind(KEYBINDS.ACCEPT_EARLY_COMPACTION)} accept early`
: `hit ${formatKeybind(vimEnabled ? KEYBINDS.INTERRUPT_STREAM_VIM : KEYBINDS.INTERRUPT_STREAM_NORMAL)} to cancel`
}
cancelText={`hit ${formatKeybind(vimEnabled ? KEYBINDS.INTERRUPT_STREAM_VIM : KEYBINDS.INTERRUPT_STREAM_NORMAL)} to cancel`}
tokenCount={
activeStreamMessageId
? aggregator.getStreamingTokenCount(activeStreamMessageId)
Expand Down
2 changes: 1 addition & 1 deletion src/browser/components/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -799,7 +799,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
const interruptKeybind = vimEnabled
? KEYBINDS.INTERRUPT_STREAM_VIM
: KEYBINDS.INTERRUPT_STREAM_NORMAL;
return `Compacting... (${formatKeybind(interruptKeybind)} cancel | ${formatKeybind(KEYBINDS.ACCEPT_EARLY_COMPACTION)} accept early | ${formatKeybind(KEYBINDS.SEND_MESSAGE)} to queue)`;
return `Compacting... (${formatKeybind(interruptKeybind)} cancel | ${formatKeybind(KEYBINDS.SEND_MESSAGE)} to queue)`;
}

// Build hints for normal input
Expand Down
17 changes: 0 additions & 17 deletions src/browser/hooks/useAIViewKeybinds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ interface UseAIViewKeybindsParams {
* - Ctrl+G: Jump to bottom
* - Ctrl+T: Open terminal
* - Ctrl+C (during compaction in vim mode): Cancel compaction, restore command
* - Ctrl+A (during compaction): Accept early with [truncated]
*
* Note: In vim mode, Ctrl+C always interrupts streams. Use vim yank (y) commands for copying.
*/
Expand All @@ -61,7 +60,6 @@ export function useAIViewKeybinds({
: KEYBINDS.INTERRUPT_STREAM_NORMAL;

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

// Ctrl+A during compaction: accept early with [truncated] sentinel
// (different from Ctrl+C which cancels and restores original state)
// Only intercept if actively compacting (otherwise allow browser default for select all)
if (matchesKeybind(e, KEYBINDS.ACCEPT_EARLY_COMPACTION)) {
if (canInterrupt && isCompactingStream(aggregator)) {
// Ctrl+A during compaction: perform compaction with partial summary
// No flag set - handleCompactionAbort will perform compaction with [truncated]
e.preventDefault();
setAutoRetry(false);
void window.api.workspace.interruptStream(workspaceId);
}
// Let browser handle Ctrl+A (select all) when not compacting
return;
}

// Focus chat input works anywhere (even in input fields)
if (matchesKeybind(e, KEYBINDS.FOCUS_CHAT)) {
e.preventDefault();
Expand Down
10 changes: 2 additions & 8 deletions src/browser/utils/compaction/handler.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,8 @@
/**
* Compaction interrupt handling
*
* Two interrupt flows during compaction:
* - Ctrl+C (cancel): Abort compaction, restore original history + command to input
* - Ctrl+A (accept early): Complete compaction with [truncated] sentinel
*
* Uses localStorage to persist cancellation intent across reloads:
* - Before interrupt, store messageId in localStorage
* - handleCompactionAbort checks localStorage and verifies messageId matches
* - Reload-safe: localStorage persists, messageId ensures freshness
* Ctrl+C (cancel): Abort compaction, enters edit mode on compaction-request message
* with original /compact command restored for re-editing.
*/

import type { StreamingMessageAggregator } from "@/browser/utils/messages/StreamingMessageAggregator";
Expand Down
3 changes: 0 additions & 3 deletions src/browser/utils/ui/keybinds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,9 +206,6 @@ export const KEYBINDS = {
INTERRUPT_STREAM_VIM: { key: "c", ctrl: true, macCtrlBehavior: "control" },
INTERRUPT_STREAM_NORMAL: { key: "Escape" },

/** Accept partial compaction early (adds [truncated] sentinel) */
ACCEPT_EARLY_COMPACTION: { key: "a", ctrl: true, macCtrlBehavior: "control" },

/** Focus chat input */
FOCUS_INPUT_I: { key: "i" },

Expand Down
10 changes: 3 additions & 7 deletions src/node/services/agentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,7 @@ import { Ok, Err } from "@/common/types/result";
import { enforceThinkingPolicy } from "@/browser/utils/thinking/policy";
import { createRuntime } from "@/node/runtime/runtimeFactory";
import { MessageQueue } from "./messageQueue";

import type { StreamEndEvent, StreamAbortEvent } from "@/common/types/stream";
import type { StreamEndEvent } from "@/common/types/stream";
import { CompactionHandler } from "./compactionHandler";

export interface AgentSessionChatEvent {
Expand Down Expand Up @@ -475,11 +474,8 @@ export class AgentSession {
this.sendQueuedMessages();
});

forward("stream-abort", async (payload) => {
const handled = await this.compactionHandler.handleAbort(payload as StreamAbortEvent);
if (!handled) {
this.emitChatEvent(payload);
}
forward("stream-abort", (payload) => {
this.emitChatEvent(payload);

// Stream aborted: restore queued messages to input
if (!this.messageQueue.isEmpty()) {
Expand Down
238 changes: 1 addition & 237 deletions src/node/services/compactionHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { CompactionHandler } from "./compactionHandler";
import type { HistoryService } from "./historyService";
import type { EventEmitter } from "events";
import { createMuxMessage, type MuxMessage } from "@/common/types/message";
import type { StreamEndEvent, StreamAbortEvent } from "@/common/types/stream";
import type { StreamEndEvent } from "@/common/types/stream";
import { Ok, Err, type Result } from "@/common/types/result";
import type { LanguageModelV2Usage } from "@ai-sdk/provider";

Expand Down Expand Up @@ -98,21 +98,6 @@ const createStreamEndEvent = (
},
});

const createStreamAbortEvent = (
abandonPartial = false,
metadata?: Record<string, unknown>
): StreamAbortEvent => ({
type: "stream-abort",
workspaceId: "test-workspace",
messageId: "msg-id",
abandonPartial,
metadata: {
usage: { inputTokens: 100, outputTokens: 25, totalTokens: undefined },
duration: 800,
...metadata,
},
});

// DRY helper to set up successful compaction scenario
const setupSuccessfulCompaction = (
mockHistoryService: ReturnType<typeof createMockHistoryService>,
Expand Down Expand Up @@ -145,227 +130,6 @@ describe("CompactionHandler", () => {
});
});

describe("handleAbort() - Ctrl+C (cancel) Flow", () => {
it("should return false when no compaction request found in history", async () => {
const normalUserMsg = createMuxMessage("msg1", "user", "Hello", {
historySequence: 0,
muxMetadata: { type: "normal" },
});
mockHistoryService.mockGetHistory(Ok([normalUserMsg]));

const event = createStreamAbortEvent(false);
const result = await handler.handleAbort(event);

expect(result).toBe(false);
expect(emittedEvents).toHaveLength(0);
});

it("should return false when abandonPartial=true (Ctrl+C cancel)", async () => {
const compactionReq = createCompactionRequest();
const assistantMsg = createAssistantMessage("Partial summary...");
mockHistoryService.mockGetHistory(Ok([compactionReq, assistantMsg]));

const event = createStreamAbortEvent(true);
const result = await handler.handleAbort(event);

expect(result).toBe(false);
expect(mockHistoryService.clearHistory.mock.calls).toHaveLength(0);
expect(emittedEvents).toHaveLength(0);
});

it("should not perform compaction when cancelled", async () => {
const compactionReq = createCompactionRequest();
const assistantMsg = createAssistantMessage("Partial summary");
mockHistoryService.mockGetHistory(Ok([compactionReq, assistantMsg]));

const event = createStreamAbortEvent(true);
await handler.handleAbort(event);

expect(mockHistoryService.clearHistory.mock.calls).toHaveLength(0);
expect(mockHistoryService.appendToHistory.mock.calls).toHaveLength(0);
});

it("should not emit events when cancelled", async () => {
const compactionReq = createCompactionRequest();
const assistantMsg = createAssistantMessage("Partial");
mockHistoryService.mockGetHistory(Ok([compactionReq, assistantMsg]));

const event = createStreamAbortEvent(true);
await handler.handleAbort(event);

expect(emittedEvents).toHaveLength(0);
});
});

describe("handleAbort() - Ctrl+A (accept early) Flow", () => {
it("should return false when last message is not assistant role", async () => {
const compactionReq = createCompactionRequest();
mockHistoryService.mockGetHistory(Ok([compactionReq]));

const event = createStreamAbortEvent(false);
const result = await handler.handleAbort(event);

expect(result).toBe(false);
});

it("should return true when successful", async () => {
const compactionReq = createCompactionRequest();
const assistantMsg = createAssistantMessage("Partial summary");
setupSuccessfulCompaction(mockHistoryService, [compactionReq, assistantMsg]);

const event = createStreamAbortEvent(false);
const result = await handler.handleAbort(event);

expect(result).toBe(true);
});

it("should read partial summary from last assistant message in history", async () => {
const compactionReq = createCompactionRequest();
const assistantMsg = createAssistantMessage("Here is a partial summary");
setupSuccessfulCompaction(mockHistoryService, [compactionReq, assistantMsg]);

const event = createStreamAbortEvent(false);
await handler.handleAbort(event);

expect(mockHistoryService.appendToHistory.mock.calls).toHaveLength(1);
const appendedMsg = mockHistoryService.appendToHistory.mock.calls[0][1] as MuxMessage;
expect((appendedMsg.parts[0] as { type: "text"; text: string }).text).toContain(
"Here is a partial summary"
);
});

it("should append [truncated] sentinel to partial summary", async () => {
const compactionReq = createCompactionRequest();
const assistantMsg = createAssistantMessage("Partial text");
setupSuccessfulCompaction(mockHistoryService, [compactionReq, assistantMsg]);

const event = createStreamAbortEvent(false);
await handler.handleAbort(event);

const appendedMsg = mockHistoryService.appendToHistory.mock.calls[0][1] as MuxMessage;
expect((appendedMsg.parts[0] as { type: "text"; text: string }).text).toContain(
"[truncated]"
);
});

it("should call clearHistory() and appendToHistory() with summary message", async () => {
const compactionReq = createCompactionRequest();
const assistantMsg = createAssistantMessage("Summary");
setupSuccessfulCompaction(mockHistoryService, [compactionReq, assistantMsg]);

const event = createStreamAbortEvent(false);
await handler.handleAbort(event);

expect(mockHistoryService.clearHistory.mock.calls).toHaveLength(1);
expect(mockHistoryService.clearHistory.mock.calls[0][0]).toBe(workspaceId);
expect(mockHistoryService.appendToHistory.mock.calls).toHaveLength(1);
expect(mockHistoryService.appendToHistory.mock.calls[0][0]).toBe(workspaceId);
const appendedMsg = mockHistoryService.appendToHistory.mock.calls[0][1] as MuxMessage;
expect(appendedMsg.role).toBe("assistant");
expect((appendedMsg.parts[0] as { type: "text"; text: string }).text).toContain(
"[truncated]"
);
});

it("should emit delete event with cleared sequence numbers", async () => {
const compactionReq = createCompactionRequest();
const assistantMsg = createAssistantMessage("Summary");
mockHistoryService.mockGetHistory(Ok([compactionReq, assistantMsg]));
mockHistoryService.mockClearHistory(Ok([0, 1, 2]));
mockHistoryService.mockAppendToHistory(Ok(undefined));

const event = createStreamAbortEvent(false);
await handler.handleAbort(event);

const deleteEvent = emittedEvents.find(
(_e) => (_e.data.message as { type?: string })?.type === "delete"
);
expect(deleteEvent).toBeDefined();
expect(deleteEvent?.data).toEqual({
workspaceId,
message: {
type: "delete",
historySequences: [0, 1, 2],
},
});
});

it("should emit summary message as assistant message", async () => {
const compactionReq = createCompactionRequest();
const assistantMsg = createAssistantMessage("Summary text");
mockHistoryService.mockGetHistory(Ok([compactionReq, assistantMsg]));
mockHistoryService.mockClearHistory(Ok([0, 1]));
mockHistoryService.mockAppendToHistory(Ok(undefined));

const event = createStreamAbortEvent(false);
await handler.handleAbort(event);

const summaryEvent = emittedEvents.find((_e) => {
const msg = _e.data.message as MuxMessage | undefined;
return msg?.role === "assistant" && msg?.parts !== undefined;
});
expect(summaryEvent).toBeDefined();
expect(summaryEvent?.data.workspaceId).toBe(workspaceId);
const summaryMsg = summaryEvent?.data.message as MuxMessage;
expect((summaryMsg.parts[0] as { type: "text"; text: string }).text).toContain("[truncated]");
});

it("should emit original stream-abort event to frontend", async () => {
const compactionReq = createCompactionRequest();
const assistantMsg = createAssistantMessage("Summary");
mockHistoryService.mockGetHistory(Ok([compactionReq, assistantMsg]));
mockHistoryService.mockClearHistory(Ok([0, 1]));
mockHistoryService.mockAppendToHistory(Ok(undefined));

const event = createStreamAbortEvent(false, { duration: 999 });
await handler.handleAbort(event);

const abortEvent = emittedEvents.find((_e) => _e.data.message === event);
expect(abortEvent).toBeDefined();
expect(abortEvent?.event).toBe("chat-event");
expect(abortEvent?.data.workspaceId).toBe(workspaceId);
const abortMsg = abortEvent?.data.message as StreamAbortEvent;
expect(abortMsg.metadata?.duration).toBe(999);
});

it("should preserve metadata (model, usage, duration, systemMessageTokens)", async () => {
const compactionReq = createCompactionRequest();
const usage = { inputTokens: 100, outputTokens: 25, totalTokens: 125 };
const assistantMsg = createAssistantMessage("Summary", {
usage,
duration: 800,
model: "claude-3-opus-20240229",
});
assistantMsg.metadata!.systemMessageTokens = 50;
mockHistoryService.mockGetHistory(Ok([compactionReq, assistantMsg]));
mockHistoryService.mockClearHistory(Ok([0, 1]));
mockHistoryService.mockAppendToHistory(Ok(undefined));

const event = createStreamAbortEvent(false, { usage, duration: 800 });
await handler.handleAbort(event);

const appendedMsg = mockHistoryService.appendToHistory.mock.calls[0][1] as MuxMessage;
expect(appendedMsg.metadata?.model).toBe("claude-3-opus-20240229");
expect(appendedMsg.metadata?.usage).toEqual(usage);
expect(appendedMsg.metadata?.duration).toBe(800);
expect(appendedMsg.metadata?.systemMessageTokens).toBe(50);
});

it("should handle empty partial text gracefully (just [truncated])", async () => {
const compactionReq = createCompactionRequest();
const assistantMsg = createAssistantMessage("");
mockHistoryService.mockGetHistory(Ok([compactionReq, assistantMsg]));
mockHistoryService.mockClearHistory(Ok([0, 1]));
mockHistoryService.mockAppendToHistory(Ok(undefined));

const event = createStreamAbortEvent(false);
await handler.handleAbort(event);

const appendedMsg = mockHistoryService.appendToHistory.mock.calls[0][1] as MuxMessage;
expect((appendedMsg.parts[0] as { type: "text"; text: string }).text).toBe("\n\n[truncated]");
});
});

describe("handleCompletion() - Normal Compaction Flow", () => {
it("should return false when no compaction request found", async () => {
const normalMsg = createMuxMessage("msg1", "user", "Hello", {
Expand Down
Loading