diff --git a/apps/desktop/electron/ipc/hub.ts b/apps/desktop/electron/ipc/hub.ts index 75d1f1b6..c8db8005 100644 --- a/apps/desktop/electron/ipc/hub.ts +++ b/apps/desktop/electron/ipc/hub.ts @@ -280,6 +280,19 @@ export function registerHubIpcHandlers(): void { return } + // Compaction events: forward with no stream tracking + const isCompactionEvent = + event.type === 'compaction_start' || event.type === 'compaction_end' + if (isCompactionEvent) { + safeLog(`[IPC] Sending compaction event to renderer: ${event.type}`) + mainWindowRef.webContents.send('localChat:event', { + agentId, + streamId: null, + event, + }) + return + } + // Filter events same as Hub.consumeAgent() const maybeMessage = (event as { message?: { role?: string } }).message const isAssistantMessage = maybeMessage?.role === 'assistant' diff --git a/apps/desktop/src/hooks/use-local-chat.ts b/apps/desktop/src/hooks/use-local-chat.ts index ebe19d0c..250858b8 100644 --- a/apps/desktop/src/hooks/use-local-chat.ts +++ b/apps/desktop/src/hooks/use-local-chat.ts @@ -78,7 +78,26 @@ export function useLocalChat({ agentId }: UseLocalChatOptions): UseLocalChatRetu // Handle agent events - same logic as connection-store.ts const agentEvent = event.event const streamId = event.streamId - if (!agentEvent || !streamId) return + if (!agentEvent) return + + // Handle compaction events (no streamId required) + if (agentEvent.type === 'compaction_start') { + store.startCompaction() + return + } + if (agentEvent.type === 'compaction_end') { + const evt = agentEvent as { removed: number; kept: number; tokensRemoved?: number; tokensKept?: number; reason: string } + store.endCompaction({ + removed: evt.removed, + kept: evt.kept, + tokensRemoved: evt.tokensRemoved, + tokensKept: evt.tokensKept, + reason: evt.reason, + }) + return + } + + if (!streamId) return if (agentEvent.type === 'message_start') { currentStreamRef.current = streamId diff --git a/packages/sdk/src/actions/index.ts b/packages/sdk/src/actions/index.ts index ab892a5a..53e3ec6e 100644 --- a/packages/sdk/src/actions/index.ts +++ b/packages/sdk/src/actions/index.ts @@ -34,6 +34,9 @@ export { StreamAction, type StreamPayload, type AgentEvent, + type CompactionEvent, + type CompactionStartEvent, + type CompactionEndEvent, type ContentBlock, type TextContent, type ThinkingContent, diff --git a/packages/sdk/src/actions/stream.ts b/packages/sdk/src/actions/stream.ts index 810f7355..c0249353 100644 --- a/packages/sdk/src/actions/stream.ts +++ b/packages/sdk/src/actions/stream.ts @@ -25,16 +25,36 @@ export type { AgentEvent }; */ export type ContentBlock = TextContent | ThinkingContent | ToolCall | ImageContent; +// --- Compaction event types (Multica-specific, not from pi-agent-core) --- + +/** Emitted when context compaction begins */ +export type CompactionStartEvent = { + type: "compaction_start"; +}; + +/** Emitted when context compaction completes */ +export type CompactionEndEvent = { + type: "compaction_end"; + removed: number; + kept: number; + tokensRemoved?: number; + tokensKept?: number; + reason: string; +}; + +/** Union of all compaction events */ +export type CompactionEvent = CompactionStartEvent | CompactionEndEvent; + // --- Stream event types --- /** - * Hub forwards AgentEvent from pi-agent-core as-is. - * StreamPayload wraps it with routing metadata. + * Hub forwards AgentEvent from pi-agent-core and CompactionEvent as-is. + * StreamPayload wraps them with routing metadata. */ export interface StreamPayload { streamId: string; agentId: string; - event: AgentEvent; + event: AgentEvent | CompactionEvent; } /** Extract thinking/reasoning content from an AgentEvent that carries a message */ diff --git a/packages/store/src/connection-store.ts b/packages/store/src/connection-store.ts index 17b41a51..5ede536a 100644 --- a/packages/store/src/connection-store.ts +++ b/packages/store/src/connection-store.ts @@ -143,6 +143,21 @@ function createClient( case "tool_execution_update": // Partial results — not rendered yet, ignored for now break + case "compaction_start": { + store.startCompaction() + break + } + case "compaction_end": { + const evt = event as { removed: number; kept: number; tokensRemoved?: number; tokensKept?: number; reason: string } + store.endCompaction({ + removed: evt.removed, + kept: evt.kept, + tokensRemoved: evt.tokensRemoved, + tokensKept: evt.tokensKept, + reason: evt.reason, + }) + break + } } return } diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts index f2ee2670..73c86225 100644 --- a/packages/store/src/index.ts +++ b/packages/store/src/index.ts @@ -2,6 +2,6 @@ export { useConnectionStore } from "./connection-store" export type { ConnectionStore } from "./connection-store" export { useAutoConnect } from "./use-auto-connect" export { useMessagesStore } from "./messages" -export type { Message, MessagesStore, SendContext, ToolStatus } from "./messages" +export type { Message, MessagesStore, SendContext, ToolStatus, CompactionStats } from "./messages" export { parseConnectionCode, saveConnection, loadConnection, clearConnection } from "./connection" export type { ConnectionInfo } from "./connection" diff --git a/packages/store/src/messages.ts b/packages/store/src/messages.ts index 45555dd6..c887e45f 100644 --- a/packages/store/src/messages.ts +++ b/packages/store/src/messages.ts @@ -15,6 +15,14 @@ import type { ContentBlock } from "@multica/sdk" export type ToolStatus = "running" | "success" | "error" | "interrupted" +export interface CompactionStats { + removed: number + kept: number + tokensRemoved?: number + tokensKept?: number + reason: string +} + export interface Message { id: string role: "user" | "assistant" | "toolResult" @@ -40,6 +48,8 @@ export interface SendContext { interface MessagesState { messages: Message[] streamingIds: Set + compacting: boolean + lastCompaction: CompactionStats | null } interface MessagesActions { @@ -56,6 +66,9 @@ interface MessagesActions { // Tool execution lifecycle startToolExecution: (agentId: string, toolCallId: string, toolName: string, args?: unknown) => void endToolExecution: (toolCallId: string, result?: unknown, isError?: boolean) => void + // Compaction lifecycle + startCompaction: () => void + endCompaction: (stats: CompactionStats) => void } export type MessagesStore = MessagesState & MessagesActions @@ -63,6 +76,8 @@ export type MessagesStore = MessagesState & MessagesActions export const useMessagesStore = create()((set, get) => ({ messages: [], streamingIds: new Set(), + compacting: false, + lastCompaction: null, sendMessage: (text, ctx) => { get().addUserMessage(text, ctx.agentId) @@ -102,7 +117,7 @@ export const useMessagesStore = create()((set, get) => ({ }, clearMessages: () => { - set({ messages: [], streamingIds: new Set() }) + set({ messages: [], streamingIds: new Set(), compacting: false, lastCompaction: null }) }, // --- Streaming: build assistant message incrementally --- @@ -180,4 +195,14 @@ export const useMessagesStore = create()((set, get) => ({ ), })) }, + + // --- Compaction lifecycle --- + + startCompaction: () => { + set({ compacting: true }) + }, + + endCompaction: (stats) => { + set({ compacting: false, lastCompaction: stats }) + }, })) diff --git a/src/hub/hub.ts b/src/hub/hub.ts index d8c02715..f76b3786 100644 --- a/src/hub/hub.ts +++ b/src/hub/hub.ts @@ -256,6 +256,18 @@ export class Hub { content: item.content, }); } else { + // Compaction events: forward with synthetic streamId (no stream tracking) + const isCompactionEvent = + item.type === "compaction_start" || item.type === "compaction_end"; + if (isCompactionEvent) { + this.client.send(targetDeviceId, StreamAction, { + streamId: `compaction:${agent.sessionId}`, + agentId: agent.sessionId, + event: item, + }); + continue; + } + // Filter: only forward events useful for frontend rendering const maybeMessage = (item as { message?: { role?: string } }).message; const isAssistantMessage = maybeMessage?.role === "assistant";