diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index 1ce697f21..759c49a2b 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -501,11 +501,7 @@ const AIViewInner: React.FC = ({ ? `${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) diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 8afb7c96b..b6cf018aa 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -799,7 +799,7 @@ export const ChatInput: React.FC = (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 diff --git a/src/browser/hooks/useAIViewKeybinds.ts b/src/browser/hooks/useAIViewKeybinds.ts index c70bb39a6..0d4ba8243 100644 --- a/src/browser/hooks/useAIViewKeybinds.ts +++ b/src/browser/hooks/useAIViewKeybinds.ts @@ -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. */ @@ -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)) { @@ -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(); diff --git a/src/browser/utils/compaction/handler.ts b/src/browser/utils/compaction/handler.ts index eaf93a0a9..ad57962af 100644 --- a/src/browser/utils/compaction/handler.ts +++ b/src/browser/utils/compaction/handler.ts @@ -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"; diff --git a/src/browser/utils/ui/keybinds.ts b/src/browser/utils/ui/keybinds.ts index 1c884e098..1884390f2 100644 --- a/src/browser/utils/ui/keybinds.ts +++ b/src/browser/utils/ui/keybinds.ts @@ -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" }, diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index b393d3a99..a7b55b3ab 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -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 { @@ -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()) { diff --git a/src/node/services/compactionHandler.test.ts b/src/node/services/compactionHandler.test.ts index 33bb1e750..175d91700 100644 --- a/src/node/services/compactionHandler.test.ts +++ b/src/node/services/compactionHandler.test.ts @@ -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"; @@ -98,21 +98,6 @@ const createStreamEndEvent = ( }, }); -const createStreamAbortEvent = ( - abandonPartial = false, - metadata?: Record -): 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, @@ -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", { diff --git a/src/node/services/compactionHandler.ts b/src/node/services/compactionHandler.ts index 43e13961f..3cb984695 100644 --- a/src/node/services/compactionHandler.ts +++ b/src/node/services/compactionHandler.ts @@ -1,6 +1,6 @@ import type { EventEmitter } from "events"; import type { HistoryService } from "./historyService"; -import type { StreamEndEvent, StreamAbortEvent } from "@/common/types/stream"; +import type { StreamEndEvent } from "@/common/types/stream"; import type { WorkspaceChatMessage, DeleteMessage } from "@/common/types/ipc"; import type { Result } from "@/common/types/result"; import { Ok, Err } from "@/common/types/result"; @@ -20,7 +20,6 @@ interface CompactionHandlerOptions { * * Responsible for: * - Detecting compaction requests in stream events - * - Handling Ctrl+C (cancel) and Ctrl+A (accept early) flows * - Replacing chat history with compacted summaries * - Preserving cumulative usage across compactions */ @@ -36,66 +35,6 @@ export class CompactionHandler { this.emitter = options.emitter; } - /** - * Handle compaction stream abort (Ctrl+C cancel or Ctrl+A accept early) - * - * Two flows: - * - Ctrl+C: abandonPartial=true → skip compaction - * - Ctrl+A: abandonPartial=false/undefined → perform compaction with [truncated] - */ - async handleAbort(event: StreamAbortEvent): Promise { - // Check if the last user message is a compaction-request - const historyResult = await this.historyService.getHistory(this.workspaceId); - if (!historyResult.success) { - return false; - } - - 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; - } - - // Ctrl+C flow: abandonPartial=true means user cancelled, skip compaction - if (event.abandonPartial === true) { - return false; - } - - // Ctrl+A flow: Accept early with [truncated] sentinel - // Get the truncated message from historyResult.data - const lastMessage = messages[messages.length - 1]; - if (!lastMessage || lastMessage.role !== "assistant") { - console.warn("[CompactionHandler] Compaction aborted but last message is not assistant"); - return false; - } - - const partialSummary = lastMessage.parts - .filter((part): part is { type: "text"; text: string } => part.type === "text") - .map((part) => part.text) - .join(""); - - // Append [truncated] sentinel - const truncatedSummary = partialSummary.trim() + "\n\n[truncated]"; - - // Perform compaction with truncated summary - const result = await this.performCompaction(truncatedSummary, messages, { - model: lastMessage.metadata?.model ?? "unknown", - usage: event.metadata?.usage, - duration: event.metadata?.duration, - providerMetadata: lastMessage.metadata?.providerMetadata, - systemMessageTokens: lastMessage.metadata?.systemMessageTokens, - }); - if (!result.success) { - console.error("[CompactionHandler] Early compaction failed:", result.error); - return false; - } - - this.emitChatEvent(event); - return true; - } - /** * Handle compaction stream completion *