diff --git a/apps/desktop/src/main/electron-env.d.ts b/apps/desktop/src/main/electron-env.d.ts index 0d52ba52..65e50105 100644 --- a/apps/desktop/src/main/electron-env.d.ts +++ b/apps/desktop/src/main/electron-env.d.ts @@ -70,18 +70,20 @@ interface SkillInfo { triggers: string[] } -interface DeviceMeta { - userAgent?: string - platform?: string - language?: string -} - -interface DeviceEntryInfo { - deviceId: string - agentId: string - addedAt: number - meta?: DeviceMeta -} +interface DeviceMeta { + userAgent?: string + platform?: string + language?: string + clientName?: string +} + +interface DeviceEntryInfo { + deviceId: string + agentId: string + conversationIds: string[] + addedAt: number + meta?: DeviceMeta +} interface SkillAddResult { ok: boolean @@ -96,10 +98,11 @@ interface ProfileData { userContent: string | undefined } -interface LocalChatEvent { - agentId: string - streamId?: string - type?: 'error' +interface LocalChatEvent { + agentId: string + conversationId: string + streamId?: string + type?: 'error' content?: string event?: { type: 'message_start' | 'message_update' | 'message_end' | 'tool_execution_start' | 'tool_execution_update' | 'tool_execution_end' | 'compaction_start' | 'compaction_end' @@ -112,11 +115,12 @@ interface LocalChatEvent { } } -interface LocalChatApproval { - approvalId: string - agentId: string - command: string - cwd?: string +interface LocalChatApproval { + approvalId: string + agentId: string + conversationId: string + command: string + cwd?: string riskLevel: 'safe' | 'needs-review' | 'dangerous' riskReasons: string[] expiresAtMs: number @@ -155,12 +159,13 @@ type MessageSource = | { type: 'gateway'; deviceId: string } | { type: 'channel'; channelId: string; accountId: string; conversationId: string } -interface InboundMessageEvent { - agentId: string - content: string - source: MessageSource - timestamp: number -} +interface InboundMessageEvent { + agentId: string + conversationId: string + content: string + source: MessageSource + timestamp: number +} interface ElectronAPI { app: { @@ -173,16 +178,16 @@ interface ElectronAPI { hub: { init: () => Promise getStatus: () => Promise - getAgentInfo: () => Promise - info: () => Promise - reconnect: (url: string) => Promise - listAgents: () => Promise - createAgent: (id?: string) => Promise - getAgent: (id: string) => Promise - closeAgent: (id: string) => Promise - sendMessage: (agentId: string, content: string) => Promise - registerToken: (token: string, agentId: string, expiresAt: number) => Promise - onDeviceConfirmRequest: (callback: (deviceId: string, meta?: DeviceMeta) => void) => void + getAgentInfo: () => Promise + info: () => Promise + reconnect: (url: string) => Promise + listConversations: () => Promise + createConversation: (id?: string) => Promise + getConversation: (id: string) => Promise + closeConversation: (id: string) => Promise + sendMessage: (agentId: string, content: string, conversationId: string) => Promise + registerToken: (token: string, agentId: string, conversationId: string, expiresAt: number) => Promise + onDeviceConfirmRequest: (callback: (deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta) => void) => void offDeviceConfirmRequest: () => void deviceConfirmResponse: (deviceId: string, allowed: boolean) => void listDevices: () => Promise @@ -246,13 +251,13 @@ interface ElectronAPI { last: () => Promise setEnabled: (enabled: boolean) => Promise<{ ok: boolean; enabled?: boolean; error?: string }> wake: (reason?: string) => Promise<{ ok: boolean; result?: unknown; error?: string }> - } + } localChat: { - subscribe: (agentId: string) => Promise<{ ok?: boolean; error?: string; alreadySubscribed?: boolean }> - unsubscribe: (agentId: string) => Promise<{ ok: boolean }> - getHistory: (agentId: string, options?: { offset?: number; limit?: number }) => Promise<{ messages: unknown[]; total: number; offset: number; limit: number; contextWindowTokens?: number }> - send: (agentId: string, content: string) => Promise<{ ok?: boolean; error?: string }> - abort: (agentId: string) => Promise<{ ok?: boolean; error?: string }> + subscribe: (conversationId: string) => Promise<{ ok?: boolean; error?: string; alreadySubscribed?: boolean }> + unsubscribe: (conversationId: string) => Promise<{ ok: boolean }> + getHistory: (conversationId: string, options?: { offset?: number; limit?: number }) => Promise<{ messages: unknown[]; total: number; offset: number; limit: number; contextWindowTokens?: number }> + send: (conversationId: string, content: string) => Promise<{ ok?: boolean; error?: string }> + abort: (conversationId: string) => Promise<{ ok?: boolean; error?: string }> resolveExecApproval: (approvalId: string, decision: string) => Promise<{ ok: boolean }> onEvent: (callback: (event: LocalChatEvent) => void) => void offEvent: () => void diff --git a/apps/desktop/src/main/ipc/agent.ts b/apps/desktop/src/main/ipc/agent.ts index dc2189f2..428198af 100644 --- a/apps/desktop/src/main/ipc/agent.ts +++ b/apps/desktop/src/main/ipc/agent.ts @@ -47,10 +47,10 @@ function getDefaultAgent() { const hub = getCurrentHub() if (!hub) return null - const agentIds = hub.listAgents() - if (agentIds.length === 0) return null + const conversationIds = hub.listConversations() + if (conversationIds.length === 0) return null - return hub.getAgent(agentIds[0]) ?? null + return hub.getConversation(conversationIds[0]) ?? null } /** diff --git a/apps/desktop/src/main/ipc/hub.ts b/apps/desktop/src/main/ipc/hub.ts index 93c4a505..c5b37822 100644 --- a/apps/desktop/src/main/ipc/hub.ts +++ b/apps/desktop/src/main/ipc/hub.ts @@ -10,7 +10,7 @@ import type { ConnectionState } from '@multica/sdk' // Singleton Hub instance let hub: Hub | null = null -let defaultAgentId: string | null = null +let defaultConversationId: string | null = null let mainWindowRef: BrowserWindow | null = null // Track which agents have active IPC subscriptions (for local direct chat) @@ -35,7 +35,7 @@ function safeLog(...args: unknown[]): void { /** * Initialize Hub on app startup. - * Creates Hub and a default Agent automatically. + * Creates Hub and a default conversation automatically. */ export async function initializeHub(): Promise { if (hub) { @@ -47,16 +47,16 @@ export async function initializeHub(): Promise { hub = new Hub(gatewayUrl) - // Create default agent if none exists - const agents = hub.listAgents() - if (agents.length === 0) { - safeLog('[Desktop] Creating default agent...') - const agent = hub.createAgent() - defaultAgentId = agent.sessionId - safeLog(`[Desktop] Default agent created: ${defaultAgentId}`) + // Create default conversation if none exists + const conversations = hub.listConversations() + if (conversations.length === 0) { + safeLog('[Desktop] Creating default conversation...') + const conversation = hub.createConversation() + defaultConversationId = conversation.sessionId + safeLog(`[Desktop] Default conversation created: ${defaultConversationId}`) } else { - defaultAgentId = agents[0] - safeLog(`[Desktop] Using existing agent: ${defaultAgentId}`) + defaultConversationId = conversations[0] + safeLog(`[Desktop] Using existing conversation: ${defaultConversationId}`) } } @@ -75,8 +75,8 @@ function getHub(): Hub { * Get the default agent. */ export function getDefaultAgent(): AsyncAgent | null { - if (!hub || !defaultAgentId) return null - return hub.getAgent(defaultAgentId) ?? null + if (!hub || !defaultConversationId) return null + return hub.getConversation(defaultConversationId) ?? null } /** @@ -111,7 +111,7 @@ export function registerHubIpcHandlers(): void { hubId: h.hubId, url: h.url, connectionState: h.connectionState, - defaultAgentId, + defaultConversationId, } }) @@ -124,7 +124,7 @@ export function registerHubIpcHandlers(): void { hubId: h.hubId, url: h.url, connectionState: h.connectionState, - agentCount: h.listAgents().length, + agentCount: h.listConversations().length, } }) @@ -138,12 +138,12 @@ export function registerHubIpcHandlers(): void { return { hubId: h.hubId, status: h.connectionState === 'connected' ? 'ready' : h.connectionState, - agentCount: h.listAgents().length, + agentCount: h.listConversations().length, gatewayConnected: h.connectionState === 'connected', gatewayUrl: h.url, defaultAgent: agent ? { - agentId: agent.sessionId, + agentId: defaultConversationId ?? agent.sessionId, status: agent.closed ? 'closed' : 'idle', } : null, @@ -159,7 +159,7 @@ export function registerHubIpcHandlers(): void { return null } return { - agentId: agent.sessionId, + agentId: defaultConversationId ?? agent.sessionId, status: agent.closed ? 'closed' : 'idle', } }) @@ -174,53 +174,53 @@ export function registerHubIpcHandlers(): void { }) /** - * List all agents. + * List all conversations. */ - ipcMain.handle('hub:listAgents', async (): Promise => { + ipcMain.handle('hub:listConversations', async (): Promise => { const h = getHub() - const agentIds = h.listAgents() - return agentIds.map((id) => { - const agent = h.getAgent(id) + const conversationIds = h.listConversations() + return conversationIds.map((id) => { + const conversation = h.getConversation(id) return { id, - closed: agent?.closed ?? true, + closed: conversation?.closed ?? true, } }) }) /** - * Create a new agent. + * Create a new conversation. */ - ipcMain.handle('hub:createAgent', async (_event, id?: string) => { + ipcMain.handle('hub:createConversation', async (_event, id?: string) => { const h = getHub() - const agent = h.createAgent(id) + const conversation = h.createConversation(id) return { - id: agent.sessionId, - closed: agent.closed, + id: conversation.sessionId, + closed: conversation.closed, } }) /** - * Get a specific agent. + * Get a specific conversation. */ - ipcMain.handle('hub:getAgent', async (_event, id: string) => { + ipcMain.handle('hub:getConversation', async (_event, id: string) => { const h = getHub() - const agent = h.getAgent(id) - if (!agent) { - return { error: `Agent not found: ${id}` } + const conversation = h.getConversation(id) + if (!conversation) { + return { error: `Conversation not found: ${id}` } } return { - id: agent.sessionId, - closed: agent.closed, + id: conversation.sessionId, + closed: conversation.closed, } }) /** - * Close/delete an agent. + * Close/delete a conversation. */ - ipcMain.handle('hub:closeAgent', async (_event, id: string) => { + ipcMain.handle('hub:closeConversation', async (_event, id: string) => { const h = getHub() - const result = h.closeAgent(id) + const result = h.closeConversation(id) return { ok: result } }) @@ -228,14 +228,15 @@ export function registerHubIpcHandlers(): void { * Send a message to an agent (for remote clients via Gateway). * Note: For local direct chat, use 'localChat:send' instead. */ - ipcMain.handle('hub:sendMessage', async (_event, agentId: string, content: string) => { + ipcMain.handle('hub:sendMessage', async (_event, agentId: string, content: string, conversationId: string) => { const h = getHub() - const agent = h.getAgent(agentId) + const resolvedConversationId = conversationId + const agent = h.getConversation(resolvedConversationId) if (!agent) { - return { error: `Agent not found: ${agentId}` } + return { error: `Conversation not found: ${resolvedConversationId}` } } if (agent.closed) { - return { error: `Agent is closed: ${agentId}` } + return { error: `Conversation is closed: ${resolvedConversationId}` } } h.channelManager.clearLastRoute() agent.write(content) @@ -246,18 +247,19 @@ export function registerHubIpcHandlers(): void { * Subscribe to local agent events (for direct IPC chat without Gateway). * Uses agent.subscribe() which supports multiple subscribers. */ - ipcMain.handle('localChat:subscribe', async (_event, agentId: string) => { + ipcMain.handle('localChat:subscribe', async (_event, conversationId: string) => { const h = getHub() - const agent = h.getAgent(agentId) - if (!agent) { - return { error: `Agent not found: ${agentId}` } + const conversation = h.getConversation(conversationId) + if (!conversation) { + return { error: `Conversation not found: ${conversationId}` } } - if (agent.closed) { - return { error: `Agent is closed: ${agentId}` } + if (conversation.closed) { + return { error: `Conversation is closed: ${conversationId}` } } + const logicalAgentId = h.getConversationAgentId(conversationId) ?? conversationId // Already subscribed? - if (ipcAgentSubscriptions.has(agentId)) { + if (ipcAgentSubscriptions.has(conversationId)) { return { ok: true, alreadySubscribed: true } } @@ -265,7 +267,7 @@ export function registerHubIpcHandlers(): void { let currentStreamId: string | null = null // Subscribe to agent events using the multi-subscriber mechanism - const unsubscribe = agent.subscribe((event) => { + const unsubscribe = conversation.subscribe((event) => { if (!mainWindowRef || mainWindowRef.isDestroyed()) { return } @@ -276,8 +278,8 @@ export function registerHubIpcHandlers(): void { if (isPassthroughEvent) { safeLog(`[IPC] Sending ${event.type} event to renderer`) mainWindowRef.webContents.send('localChat:event', { - agentId, - streamId: null, + agentId: logicalAgentId, + conversationId, event, }) return @@ -304,7 +306,8 @@ export function registerHubIpcHandlers(): void { safeLog(`[IPC] Sending event to renderer: ${event.type}, streamId: ${currentStreamId}`) mainWindowRef.webContents.send('localChat:event', { - agentId, + agentId: logicalAgentId, + conversationId, streamId: currentStreamId, event, }) @@ -315,16 +318,16 @@ export function registerHubIpcHandlers(): void { } }) - ipcAgentSubscriptions.set(agentId, unsubscribe) + ipcAgentSubscriptions.set(conversationId, unsubscribe) // Register local approval handler so exec approval requests route via IPC - h.setLocalApprovalHandler(agentId, (payload) => { + h.setLocalApprovalHandler(conversationId, (payload) => { if (!mainWindowRef || mainWindowRef.isDestroyed()) return safeLog(`[IPC] Sending approval request to renderer: ${payload.approvalId}`) mainWindowRef.webContents.send('localChat:approval', payload) }) - safeLog(`[IPC] Local chat subscribed to agent: ${agentId}`) + safeLog(`[IPC] Local chat subscribed to conversation: ${conversationId}`) return { ok: true } }) @@ -332,14 +335,14 @@ export function registerHubIpcHandlers(): void { /** * Unsubscribe from local agent events. */ - ipcMain.handle('localChat:unsubscribe', async (_event, agentId: string) => { - const unsubscribe = ipcAgentSubscriptions.get(agentId) + ipcMain.handle('localChat:unsubscribe', async (_event, conversationId: string) => { + const unsubscribe = ipcAgentSubscriptions.get(conversationId) if (unsubscribe) { unsubscribe() } - ipcAgentSubscriptions.delete(agentId) - getHub().removeLocalApprovalHandler(agentId) - safeLog(`[IPC] Local chat unsubscribed from agent: ${agentId}`) + ipcAgentSubscriptions.delete(conversationId) + getHub().removeLocalApprovalHandler(conversationId) + safeLog(`[IPC] Local chat unsubscribed from conversation: ${conversationId}`) return { ok: true } }) @@ -351,9 +354,13 @@ export function registerHubIpcHandlers(): void { * Reads from session storage (not in-memory state) so that internal * orchestration messages are excluded by default. */ - ipcMain.handle('localChat:getHistory', async (_event, agentId: string, options?: { offset?: number; limit?: number }) => { + ipcMain.handle('localChat:getHistory', async ( + _event, + conversationId: string, + options?: { offset?: number; limit?: number }, + ) => { const h = getHub() - const agent = h.getAgent(agentId) + const agent = h.getConversation(conversationId) if (!agent) { return { messages: [], total: 0, offset: 0, limit: 0, contextWindowTokens: undefined } } @@ -377,46 +384,49 @@ export function registerHubIpcHandlers(): void { * Send a message via local direct IPC (no Gateway). * Events will be pushed to renderer via 'localChat:event' channel. */ - ipcMain.handle('localChat:send', async (_event, agentId: string, content: string) => { + ipcMain.handle('localChat:send', async (_event, conversationId: string, content: string) => { const h = getHub() - const agent = h.getAgent(agentId) + const resolvedConversationId = conversationId + const agent = h.getConversation(resolvedConversationId) if (!agent) { - return { error: `Agent not found: ${agentId}` } + return { error: `Conversation not found: ${resolvedConversationId}` } } if (agent.closed) { - return { error: `Agent is closed: ${agentId}` } + return { error: `Conversation is closed: ${resolvedConversationId}` } } // Must be subscribed first to receive events - if (!ipcAgentSubscriptions.has(agentId)) { - return { error: 'Not subscribed to agent events. Call subscribe first.' } + if (!ipcAgentSubscriptions.has(resolvedConversationId)) { + return { error: 'Not subscribed to conversation events. Call subscribe first.' } } h.channelManager.clearLastRoute() const source = { type: 'local' as const } // Broadcast as local source (for consistency, though UI already knows) h.broadcastInbound({ - agentId, + agentId: h.getConversationAgentId(resolvedConversationId) ?? resolvedConversationId, + conversationId: resolvedConversationId, content, source, timestamp: Date.now(), }) agent.write(content, { source }) - safeLog(`[IPC] Local chat message sent to agent: ${agentId}`) + safeLog(`[IPC] Local chat message sent to conversation: ${resolvedConversationId}`) return { ok: true } }) /** * Abort the current agent run for local chat. */ - ipcMain.handle('localChat:abort', async (_event, agentId: string) => { + ipcMain.handle('localChat:abort', async (_event, conversationId: string) => { const h = getHub() - const agent = h.getAgent(agentId) + const resolvedConversationId = conversationId + const agent = h.getConversation(resolvedConversationId) if (!agent) { - return { error: `Agent not found: ${agentId}` } + return { error: `Conversation not found: ${resolvedConversationId}` } } agent.abort() - safeLog(`[IPC] Abort sent to agent: ${agentId}`) + safeLog(`[IPC] Abort sent to conversation: ${resolvedConversationId}`) return { ok: true } }) @@ -433,11 +443,14 @@ export function registerHubIpcHandlers(): void { * Register a one-time token for device verification. * Called by the QR code component when a token is generated or refreshed. */ - ipcMain.handle('hub:registerToken', async (_event, token: string, agentId: string, expiresAt: number) => { + ipcMain.handle( + 'hub:registerToken', + async (_event, token: string, agentId: string, conversationId: string, expiresAt: number) => { const h = getHub() - h.registerToken(token, agentId, expiresAt) + h.registerToken(token, agentId, conversationId, expiresAt) return { ok: true } - }) + }, + ) /** * List all verified (whitelisted) devices. @@ -483,7 +496,7 @@ export function setupDeviceConfirmation(mainWindow: Electron.BrowserWindow): voi }) // Register confirm handler on Hub — sends request to renderer, awaits response - h.setConfirmHandler((deviceId: string, _agentId: string, meta) => { + h.setConfirmHandler((deviceId: string, agentId: string, conversationId: string, meta) => { return new Promise((resolve) => { // Auto-reject if user doesn't respond within 60 seconds const timeout = setTimeout(() => { @@ -498,7 +511,7 @@ export function setupDeviceConfirmation(mainWindow: Electron.BrowserWindow): voi mainWindow.webContents.send('hub:devices-changed') } }) - mainWindow.webContents.send('hub:device-confirm-request', deviceId, meta) + mainWindow.webContents.send('hub:device-confirm-request', deviceId, agentId, conversationId, meta) }) }) diff --git a/apps/desktop/src/main/ipc/profile.ts b/apps/desktop/src/main/ipc/profile.ts index 36ac1512..4b149aaf 100644 --- a/apps/desktop/src/main/ipc/profile.ts +++ b/apps/desktop/src/main/ipc/profile.ts @@ -13,10 +13,10 @@ function getDefaultAgent() { const hub = getCurrentHub() if (!hub) return null - const agentIds = hub.listAgents() - if (agentIds.length === 0) return null + const conversationIds = hub.listConversations() + if (conversationIds.length === 0) return null - return hub.getAgent(agentIds[0]) ?? null + return hub.getConversation(conversationIds[0]) ?? null } /** diff --git a/apps/desktop/src/main/ipc/provider.ts b/apps/desktop/src/main/ipc/provider.ts index db27c50e..20bfe6b1 100644 --- a/apps/desktop/src/main/ipc/provider.ts +++ b/apps/desktop/src/main/ipc/provider.ts @@ -53,10 +53,10 @@ function getDefaultAgent() { const hub = getCurrentHub() if (!hub) return null - const agentIds = hub.listAgents() - if (agentIds.length === 0) return null + const conversationIds = hub.listConversations() + if (conversationIds.length === 0) return null - return hub.getAgent(agentIds[0]) ?? null + return hub.getConversation(conversationIds[0]) ?? null } /** diff --git a/apps/desktop/src/main/ipc/skills.ts b/apps/desktop/src/main/ipc/skills.ts index 368c1d09..b96079f2 100644 --- a/apps/desktop/src/main/ipc/skills.ts +++ b/apps/desktop/src/main/ipc/skills.ts @@ -27,10 +27,10 @@ function getDefaultAgent() { const hub = getCurrentHub() if (!hub) return null - const agentIds = hub.listAgents() - if (agentIds.length === 0) return null + const conversationIds = hub.listConversations() + if (conversationIds.length === 0) return null - return hub.getAgent(agentIds[0]) ?? null + return hub.getConversation(conversationIds[0]) ?? null } /** diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 07e6067a..d1fa36cf 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -65,21 +65,22 @@ export interface CurrentProviderInfo { } // Local chat event types (for direct IPC communication without Gateway) -export interface LocalChatEvent { - agentId: string - streamId?: string - type?: 'error' - content?: string +export interface LocalChatEvent { + agentId: string + conversationId: string + streamId?: string + type?: 'error' + content?: string event?: { type: 'message_start' | 'message_update' | 'message_end' | 'tool_execution_start' | 'tool_execution_update' | 'tool_execution_end' | 'compaction_start' | 'compaction_end' id?: string message?: { role: string - content?: Array<{ type: string; text?: string }> - } - [key: string]: unknown - } -} + content?: Array<{ type: string; text?: string }> + } + [key: string]: unknown + } +} // Inbound message event (from any source: local, gateway, channel) export type MessageSource = @@ -87,19 +88,21 @@ export type MessageSource = | { type: 'gateway'; deviceId: string } | { type: 'channel'; channelId: string; accountId: string; conversationId: string } -export interface InboundMessageEvent { - agentId: string - content: string - source: MessageSource - timestamp: number -} +export interface InboundMessageEvent { + agentId: string + conversationId: string + content: string + source: MessageSource + timestamp: number +} // Local chat approval request (mirrors ExecApprovalRequestPayload from @multica/sdk) -export interface LocalChatApproval { - approvalId: string - agentId: string - command: string - cwd?: string +export interface LocalChatApproval { + approvalId: string + agentId: string + conversationId: string + command: string + cwd?: string riskLevel: 'safe' | 'needs-review' | 'dangerous' riskReasons: string[] expiresAtMs: number @@ -154,20 +157,36 @@ const electronAPI = { hub: { init: () => ipcRenderer.invoke('hub:init'), getStatus: (): Promise => ipcRenderer.invoke('hub:getStatus'), - getAgentInfo: (): Promise => ipcRenderer.invoke('hub:getAgentInfo'), - info: () => ipcRenderer.invoke('hub:info'), - reconnect: (url: string) => ipcRenderer.invoke('hub:reconnect', url), - listAgents: () => ipcRenderer.invoke('hub:listAgents'), - createAgent: (id?: string) => ipcRenderer.invoke('hub:createAgent', id), - getAgent: (id: string) => ipcRenderer.invoke('hub:getAgent', id), - closeAgent: (id: string) => ipcRenderer.invoke('hub:closeAgent', id), - sendMessage: (agentId: string, content: string) => - ipcRenderer.invoke('hub:sendMessage', agentId, content), - registerToken: (token: string, agentId: string, expiresAt: number) => - ipcRenderer.invoke('hub:registerToken', token, agentId, expiresAt), - onDeviceConfirmRequest: (callback: (deviceId: string, meta?: { userAgent?: string; platform?: string; language?: string }) => void) => { - ipcRenderer.on('hub:device-confirm-request', (_event, deviceId: string, meta?: { userAgent?: string; platform?: string; language?: string }) => callback(deviceId, meta)) - }, + getAgentInfo: (): Promise => ipcRenderer.invoke('hub:getAgentInfo'), + info: () => ipcRenderer.invoke('hub:info'), + reconnect: (url: string) => ipcRenderer.invoke('hub:reconnect', url), + listConversations: () => ipcRenderer.invoke('hub:listConversations'), + createConversation: (id?: string) => ipcRenderer.invoke('hub:createConversation', id), + getConversation: (id: string) => ipcRenderer.invoke('hub:getConversation', id), + closeConversation: (id: string) => ipcRenderer.invoke('hub:closeConversation', id), + sendMessage: (agentId: string, content: string, conversationId: string) => + ipcRenderer.invoke('hub:sendMessage', agentId, content, conversationId), + registerToken: (token: string, agentId: string, conversationId: string, expiresAt: number) => + ipcRenderer.invoke('hub:registerToken', token, agentId, conversationId, expiresAt), + onDeviceConfirmRequest: ( + callback: ( + deviceId: string, + agentId: string, + conversationId: string, + meta?: { userAgent?: string; platform?: string; language?: string; clientName?: string }, + ) => void, + ) => { + ipcRenderer.on( + 'hub:device-confirm-request', + ( + _event, + deviceId: string, + agentId: string, + conversationId: string, + meta?: { userAgent?: string; platform?: string; language?: string; clientName?: string }, + ) => callback(deviceId, agentId, conversationId, meta), + ) + }, offDeviceConfirmRequest: () => { ipcRenderer.removeAllListeners('hub:device-confirm-request') }, @@ -310,19 +329,21 @@ const electronAPI = { }, }, - // Local chat (direct IPC, no Gateway required) - localChat: { - /** Subscribe to agent events for local direct chat */ - subscribe: (agentId: string) => ipcRenderer.invoke('localChat:subscribe', agentId), - /** Unsubscribe from agent events */ - unsubscribe: (agentId: string) => ipcRenderer.invoke('localChat:unsubscribe', agentId), - /** Get message history for local chat with pagination (returns raw AgentMessageItem[]) */ - getHistory: (agentId: string, options?: { offset?: number; limit?: number }) => - ipcRenderer.invoke('localChat:getHistory', agentId, options), - /** Send message to agent via direct IPC (no Gateway) */ - send: (agentId: string, content: string) => ipcRenderer.invoke('localChat:send', agentId, content), - /** Abort the current agent run */ - abort: (agentId: string) => ipcRenderer.invoke('localChat:abort', agentId), + // Local chat (direct IPC, no Gateway required) + localChat: { + /** Subscribe to conversation events for local direct chat */ + subscribe: (conversationId: string) => ipcRenderer.invoke('localChat:subscribe', conversationId), + /** Unsubscribe from conversation events */ + unsubscribe: (conversationId: string) => ipcRenderer.invoke('localChat:unsubscribe', conversationId), + /** Get message history for local chat with pagination (returns raw AgentMessageItem[]) */ + getHistory: (conversationId: string, options?: { offset?: number; limit?: number }) => + ipcRenderer.invoke('localChat:getHistory', conversationId, options), + /** Send message to conversation via direct IPC (no Gateway) */ + send: (conversationId: string, content: string) => + ipcRenderer.invoke('localChat:send', conversationId, content), + /** Abort the current agent run */ + abort: (conversationId: string) => + ipcRenderer.invoke('localChat:abort', conversationId), /** Resolve an exec approval request */ resolveExecApproval: (approvalId: string, decision: string) => ipcRenderer.invoke('localChat:resolveExecApproval', approvalId, decision), diff --git a/apps/desktop/src/renderer/src/components/device-confirm-dialog.tsx b/apps/desktop/src/renderer/src/components/device-confirm-dialog.tsx index 4b49aaad..8e6e8b3c 100644 --- a/apps/desktop/src/renderer/src/components/device-confirm-dialog.tsx +++ b/apps/desktop/src/renderer/src/components/device-confirm-dialog.tsx @@ -20,6 +20,8 @@ interface DeviceMeta { interface PendingConfirm { deviceId: string + agentId: string + conversationId: string meta?: DeviceMeta } @@ -32,9 +34,11 @@ export function DeviceConfirmDialog() { const [pending, setPending] = useState(null) useEffect(() => { - window.electronAPI?.hub.onDeviceConfirmRequest((deviceId: string, meta?: DeviceMeta) => { - setPending({ deviceId, meta }) - }) + window.electronAPI?.hub.onDeviceConfirmRequest( + (deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta) => { + setPending({ deviceId, agentId, conversationId, meta }) + }, + ) return () => { window.electronAPI?.hub.offDeviceConfirmRequest() } diff --git a/apps/desktop/src/renderer/src/components/local-chat.tsx b/apps/desktop/src/renderer/src/components/local-chat.tsx index 469f0e49..5e41b97d 100644 --- a/apps/desktop/src/renderer/src/components/local-chat.tsx +++ b/apps/desktop/src/renderer/src/components/local-chat.tsx @@ -10,12 +10,13 @@ import { QueuedMessageBar } from './queued-message-bar' interface LocalChatProps { initialPrompt?: string + conversationId?: string } -export function LocalChat({ initialPrompt }: LocalChatProps) { +export function LocalChat({ initialPrompt, conversationId }: LocalChatProps) { const navigate = useNavigate() const { - agentId, + conversationId: activeConversationId, initError, messages, streamingIds, @@ -34,7 +35,7 @@ export function LocalChat({ initialPrompt }: LocalChatProps) { loadMore, resolveApproval, clearError, - } = useLocalChat() + } = useLocalChat({ conversationId }) const { providers, current, setProvider: switchProvider, refresh: refreshProviders } = useProviderStore() @@ -71,18 +72,21 @@ export function LocalChat({ initialPrompt }: LocalChatProps) { // Auto-send initial prompt after a short delay const lastPromptRef = useRef(undefined) useEffect(() => { - if (!agentId || !initialPrompt) return + if (!activeConversationId || !initialPrompt) return if (initialPrompt === lastPromptRef.current) return const timer = setTimeout(() => { lastPromptRef.current = initialPrompt sendMessage(initialPrompt) // Remove prompt from URL to prevent re-sending on back navigation - navigate('/chat', { replace: true }) + const nextPath = activeConversationId + ? `/chat?conversation=${encodeURIComponent(activeConversationId)}` + : '/chat' + navigate(nextPath, { replace: true }) }, 500) return () => clearTimeout(timer) - }, [agentId, initialPrompt, sendMessage, navigate]) + }, [activeConversationId, initialPrompt, sendMessage, navigate]) if (initError) { return ( @@ -92,7 +96,7 @@ export function LocalChat({ initialPrompt }: LocalChatProps) { ) } - if (!agentId) { + if (!activeConversationId) { return (
diff --git a/apps/desktop/src/renderer/src/components/qr-code.tsx b/apps/desktop/src/renderer/src/components/qr-code.tsx index 413f37d6..baedb220 100644 --- a/apps/desktop/src/renderer/src/components/qr-code.tsx +++ b/apps/desktop/src/renderer/src/components/qr-code.tsx @@ -11,6 +11,7 @@ export interface QRCodeData { gateway: string hubId: string agentId: string + conversationId?: string token: string expires: number } @@ -19,6 +20,7 @@ export interface ConnectionQRCodeProps { gateway: string hubId: string agentId: string + conversationId: string expirySeconds?: number size?: number } @@ -125,10 +127,11 @@ export function ConnectionQRCode({ gateway, hubId, agentId, + conversationId, expirySeconds = 30, size = 200, }: ConnectionQRCodeProps) { - const { token, expiresAt, refresh } = useQRToken(agentId, expirySeconds) + const { token, expiresAt, refresh } = useQRToken(agentId, conversationId, expirySeconds) const remaining = useCountdown(expiresAt, refresh) // Derive QR data and URL from current token (computed during render) @@ -138,10 +141,11 @@ export function ConnectionQRCode({ gateway, hubId, agentId, + conversationId, token, expires: expiresAt, }), - [gateway, hubId, agentId, token, expiresAt] + [gateway, hubId, agentId, conversationId, token, expiresAt] ) const connectionUrl = useMemo(() => { @@ -149,11 +153,12 @@ export function ConnectionQRCode({ gateway, hub: hubId, agent: agentId, + conversation: conversationId, token, exp: expiresAt.toString(), }) return `multica://connect?${params.toString()}` - }, [gateway, hubId, agentId, token, expiresAt]) + }, [gateway, hubId, agentId, conversationId, token, expiresAt]) return (
diff --git a/apps/desktop/src/renderer/src/components/qr-hooks.ts b/apps/desktop/src/renderer/src/components/qr-hooks.ts index 6e250335..e5e217fa 100644 --- a/apps/desktop/src/renderer/src/components/qr-hooks.ts +++ b/apps/desktop/src/renderer/src/components/qr-hooks.ts @@ -11,7 +11,7 @@ function generateToken(): string { * - Auto-refreshes when expired * - Registers token with Hub */ -export function useQRToken(agentId: string, expirySeconds: number) { +export function useQRToken(agentId: string, conversationId: string, expirySeconds: number) { const [token, setToken] = useState(generateToken) const [expiresAt, setExpiresAt] = useState(() => Date.now() + expirySeconds * 1000) @@ -20,12 +20,12 @@ export function useQRToken(agentId: string, expirySeconds: number) { const newExpiry = Date.now() + expirySeconds * 1000 setToken(newToken) setExpiresAt(newExpiry) - window.electronAPI?.hub.registerToken(newToken, agentId, newExpiry) - }, [agentId, expirySeconds]) + window.electronAPI?.hub.registerToken(newToken, agentId, conversationId, newExpiry) + }, [agentId, conversationId, expirySeconds]) // Register initial token useEffect(() => { - window.electronAPI?.hub.registerToken(token, agentId, expiresAt) + window.electronAPI?.hub.registerToken(token, agentId, conversationId, expiresAt) }, []) // eslint-disable-line react-hooks/exhaustive-deps return { token, expiresAt, refresh } diff --git a/apps/desktop/src/renderer/src/components/telegram-qr.tsx b/apps/desktop/src/renderer/src/components/telegram-qr.tsx index cc99679d..c079602c 100644 --- a/apps/desktop/src/renderer/src/components/telegram-qr.tsx +++ b/apps/desktop/src/renderer/src/components/telegram-qr.tsx @@ -8,6 +8,7 @@ export interface TelegramConnectQRProps { gateway: string hubId: string agentId: string + conversationId: string expirySeconds?: number size?: number } @@ -23,10 +24,11 @@ export function TelegramConnectQR({ gateway, hubId, agentId, + conversationId, expirySeconds = 30, size = 200, }: TelegramConnectQRProps) { - const { token, expiresAt, refresh } = useQRToken(agentId, expirySeconds) + const { token, expiresAt, refresh } = useQRToken(agentId, conversationId, expirySeconds) const remaining = useCountdown(expiresAt, refresh) const [deepLink, setDeepLink] = useState(null) @@ -44,7 +46,14 @@ export function TelegramConnectQR({ const res = await fetch(`${gateway}/telegram/connect-code`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ gateway, hubId, agentId, token, expires: expiresAt }), + body: JSON.stringify({ + gateway, + hubId, + agentId, + conversationId, + token, + expires: expiresAt, + }), }) if (cancelled) return @@ -72,7 +81,7 @@ export function TelegramConnectQR({ fetchCode() return () => { cancelled = true } - }, [token, expiresAt, gateway, hubId, agentId]) + }, [token, expiresAt, gateway, hubId, agentId, conversationId]) if (loading) { return ( diff --git a/apps/desktop/src/renderer/src/hooks/use-devices.ts b/apps/desktop/src/renderer/src/hooks/use-devices.ts index 082f8921..afd493be 100644 --- a/apps/desktop/src/renderer/src/hooks/use-devices.ts +++ b/apps/desktop/src/renderer/src/hooks/use-devices.ts @@ -14,6 +14,7 @@ export interface DeviceMeta { export interface DeviceEntry { deviceId: string agentId: string + conversationIds: string[] addedAt: number meta?: DeviceMeta } diff --git a/apps/desktop/src/renderer/src/hooks/use-local-chat.ts b/apps/desktop/src/renderer/src/hooks/use-local-chat.ts index b75f6d53..15762177 100644 --- a/apps/desktop/src/renderer/src/hooks/use-local-chat.ts +++ b/apps/desktop/src/renderer/src/hooks/use-local-chat.ts @@ -15,11 +15,16 @@ export interface QueuedLocalMessage { createdAt: number } +interface UseLocalChatOptions { + conversationId?: string +} + function makeQueueId(): string { return globalThis.crypto?.randomUUID?.() ?? `queued-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` } -export function useLocalChat() { +export function useLocalChat(options: UseLocalChatOptions = {}) { + const requestedConversationId = options.conversationId const chat = useChat() const chatRef = useRef(chat) chatRef.current = chat @@ -33,6 +38,7 @@ export function useLocalChat() { const [initError, setInitError] = useState(null) const initRef = useRef(false) const offsetRef = useRef(null) + const activeConversationId = requestedConversationId ?? agentId // Initialize hub and get default agent ID useEffect(() => { @@ -41,10 +47,13 @@ export function useLocalChat() { window.electronAPI.hub.init() .then((result) => { - const r = result as { defaultAgentId?: string } - console.log('[LocalChat] hub.init → defaultAgentId:', r.defaultAgentId) - if (r.defaultAgentId) { - setAgentId(r.defaultAgentId) + const r = result as { defaultConversationId?: string } + const defaultConversationId = r.defaultConversationId + console.log('[LocalChat] hub.init → defaultConversationId:', defaultConversationId) + if (defaultConversationId) { + setAgentId(defaultConversationId) + } else if (requestedConversationId) { + setAgentId(requestedConversationId) } else { setInitError('No default agent available') setIsLoadingHistory(false) @@ -54,14 +63,19 @@ export function useLocalChat() { setInitError(err.message) setIsLoadingHistory(false) }) - }, []) + }, [requestedConversationId]) - // Subscribe to events + fetch history once agentId is available + // Subscribe to events + fetch history once conversation is available useEffect(() => { - if (!agentId) return + if (!activeConversationId) return + setQueuedMessages([]) + offsetRef.current = null + setIsLoading(false) + setIsLoadingHistory(true) + chatRef.current.reset() // Subscribe to agent events - window.electronAPI.localChat.subscribe(agentId).catch(() => {}) + window.electronAPI.localChat.subscribe(activeConversationId).catch(() => {}) // Listen for stream events window.electronAPI.localChat.onEvent((data) => { @@ -104,24 +118,38 @@ export function useLocalChat() { // Listen for inbound messages from all sources (gateway, channel) // This allows the local UI to display messages from other sources window.electronAPI.hub.onInboundMessage((event: InboundMessageEvent) => { + const eventConversationId = event.conversationId // Only add non-local messages (local messages are added by sendMessage) - if (event.source.type !== 'local' && event.agentId === agentId) { - chatRef.current.addUserMessage(event.content, event.agentId, event.source as MessageSource) + if (event.source.type !== 'local' && eventConversationId === activeConversationId) { + chatRef.current.addUserMessage( + event.content, + event.agentId, + eventConversationId, + event.source as MessageSource, + ) setIsLoading(true) } }) // Fetch history with pagination - window.electronAPI.localChat.getHistory(agentId, { limit: DEFAULT_MESSAGES_LIMIT }) + window.electronAPI.localChat.getHistory(activeConversationId, { + limit: DEFAULT_MESSAGES_LIMIT, + }) .then((result) => { console.log('[LocalChat] getHistory result:', result.messages?.length, 'messages, total:', result.total) if (result.messages?.length) { - chatRef.current.setHistory(result.messages as AgentMessageItem[], agentId, { + chatRef.current.setHistory(result.messages as AgentMessageItem[], activeConversationId, activeConversationId, { total: result.total, offset: result.offset, contextWindowTokens: result.contextWindowTokens, }) offsetRef.current = result.offset + } else { + chatRef.current.setHistory([], activeConversationId, activeConversationId, { + total: 0, + offset: 0, + contextWindowTokens: result.contextWindowTokens, + }) } }) .catch(() => {}) @@ -131,9 +159,9 @@ export function useLocalChat() { window.electronAPI.localChat.offEvent() window.electronAPI.localChat.offApproval() window.electronAPI.hub.offInboundMessage() - window.electronAPI.localChat.unsubscribe(agentId).catch(() => {}) + window.electronAPI.localChat.unsubscribe(activeConversationId).catch(() => {}) } - }, [agentId]) + }, [activeConversationId]) useEffect(() => { isLoadingRef.current = isLoading @@ -141,11 +169,11 @@ export function useLocalChat() { const dispatchMessageNow = useCallback((text: string) => { const trimmed = text.trim() - if (!trimmed || !agentId) return - chatRef.current.addUserMessage(trimmed, agentId, { type: 'local' }) + if (!trimmed || !activeConversationId) return + chatRef.current.addUserMessage(trimmed, activeConversationId, activeConversationId, { type: 'local' }) chatRef.current.setError(null) setIsLoading(true) - window.electronAPI.localChat.send(agentId, trimmed) + window.electronAPI.localChat.send(activeConversationId, trimmed) .then((result) => { const response = result as { ok?: boolean; error?: string } | undefined if (response?.error) { @@ -155,11 +183,11 @@ export function useLocalChat() { .catch(() => { setIsLoading(false) }) - }, [agentId]) + }, [activeConversationId]) const sendMessage = useCallback((text: string) => { const trimmed = text.trim() - if (!trimmed || !agentId) return + if (!trimmed || !activeConversationId) return if (isLoadingRef.current) { setQueuedMessages((prev) => [ @@ -174,7 +202,7 @@ export function useLocalChat() { } dispatchMessageNow(trimmed) - }, [agentId, dispatchMessageNow]) + }, [activeConversationId, dispatchMessageNow]) const removeQueuedMessage = useCallback((id: string) => { setQueuedMessages((prev) => prev.filter((item) => item.id !== id)) @@ -185,31 +213,34 @@ export function useLocalChat() { }, []) useEffect(() => { - if (!agentId || isLoading || queuedMessages.length === 0) return + if (!activeConversationId || isLoading || queuedMessages.length === 0) return const next = queuedMessages[0] if (!next) return setQueuedMessages((prev) => prev.slice(1)) dispatchMessageNow(next.text) - }, [agentId, isLoading, queuedMessages, dispatchMessageNow]) + }, [activeConversationId, isLoading, queuedMessages, dispatchMessageNow]) const abortGeneration = useCallback(() => { - if (!agentId) return - window.electronAPI.localChat.abort(agentId).catch(() => {}) + if (!activeConversationId) return + window.electronAPI.localChat.abort(activeConversationId).catch(() => {}) setIsLoading(false) - }, [agentId]) + }, [activeConversationId]) const loadMore = useCallback(async () => { const currentOffset = offsetRef.current - if (!agentId || currentOffset == null || currentOffset <= 0 || isLoadingMoreRef.current) return + if (!activeConversationId || currentOffset == null || currentOffset <= 0 || isLoadingMoreRef.current) return isLoadingMoreRef.current = true setIsLoadingMore(true) try { const newOffset = Math.max(0, currentOffset - DEFAULT_MESSAGES_LIMIT) const limit = currentOffset - newOffset - const result = await window.electronAPI.localChat.getHistory(agentId, { offset: newOffset, limit }) + const result = await window.electronAPI.localChat.getHistory(activeConversationId, { + offset: newOffset, + limit, + }) if (result.messages?.length) { - chatRef.current.prependHistory(result.messages as AgentMessageItem[], agentId, { + chatRef.current.prependHistory(result.messages as AgentMessageItem[], activeConversationId, activeConversationId, { total: result.total, offset: result.offset, contextWindowTokens: result.contextWindowTokens, @@ -222,7 +253,7 @@ export function useLocalChat() { isLoadingMoreRef.current = false setIsLoadingMore(false) } - }, [agentId]) + }, [activeConversationId]) const resolveApproval = useCallback( (approvalId: string, decision: ApprovalDecision) => { @@ -238,6 +269,7 @@ export function useLocalChat() { return { agentId, + conversationId: activeConversationId, initError, messages: chat.messages, streamingIds: chat.streamingIds, diff --git a/apps/desktop/src/renderer/src/pages/admin.tsx b/apps/desktop/src/renderer/src/pages/admin.tsx index 9e14fd79..a8d74510 100644 --- a/apps/desktop/src/renderer/src/pages/admin.tsx +++ b/apps/desktop/src/renderer/src/pages/admin.tsx @@ -118,6 +118,7 @@ export default function HomePage() { gateway={hubInfo?.url ?? 'http://localhost:3000'} hubId={hubInfo?.hubId ?? 'unknown'} agentId={primaryAgent?.id} + conversationId={primaryAgent?.id} expirySeconds={30} size={180} /> diff --git a/apps/desktop/src/renderer/src/pages/clients.tsx b/apps/desktop/src/renderer/src/pages/clients.tsx index 309968ed..8aa6dbed 100644 --- a/apps/desktop/src/renderer/src/pages/clients.tsx +++ b/apps/desktop/src/renderer/src/pages/clients.tsx @@ -34,6 +34,7 @@ function ChannelsContent() { gateway={hubInfo?.url ?? 'http://localhost:3000'} hubId={hubInfo?.hubId ?? 'unknown'} agentId={primaryAgent?.id ?? 'unknown'} + conversationId={primaryAgent?.id ?? 'unknown'} expirySeconds={30} size={200} /> diff --git a/apps/desktop/src/renderer/src/pages/layout.tsx b/apps/desktop/src/renderer/src/pages/layout.tsx index 9cef56e4..19e5a702 100644 --- a/apps/desktop/src/renderer/src/pages/layout.tsx +++ b/apps/desktop/src/renderer/src/pages/layout.tsx @@ -12,6 +12,7 @@ import { MessageSquare, Users, Clock, + Plus, ChevronLeft, ChevronRight, ChevronDown, @@ -48,6 +49,7 @@ import { LocalChat } from '../components/local-chat' import { DeviceConfirmDialog } from '../components/device-confirm-dialog' import { UpdateNotification } from '../components/update-notification' import { useAuthStore } from '../stores/auth' +import { useHubStore } from '../stores/hub' const mainNavItems = [ { path: '/', label: 'Home', icon: Home, exact: true }, @@ -72,6 +74,11 @@ const allNavItems: Array<{ path: string; label: string; icon: typeof Home; exact ...bottomNavItems, ] +function shortConversationId(id: string): string { + if (id.length <= 18) return id + return `${id.slice(0, 8)}...${id.slice(-8)}` +} + function NavigationButtons() { const navigate = useNavigate() useLocation() @@ -155,6 +162,13 @@ export default function Layout() { const isAgentActive = location.pathname.startsWith('/agent') const isOnChat = location.pathname === '/chat' const { user, clearAuth } = useAuthStore() + const { agents, refresh: refreshAgents, createConversation } = useHubStore() + const [isCreatingConversation, setIsCreatingConversation] = useState(false) + const [conversationError, setConversationError] = useState(null) + const selectedConversationId = isOnChat + ? new URLSearchParams(location.search).get('conversation') ?? undefined + : undefined + const activeConversationId = selectedConversationId ?? agents[0]?.id // Lazy mount: only mount Chat on first visit, then keep it mounted forever const [chatMounted, setChatMounted] = useState(false) @@ -162,6 +176,19 @@ export default function Layout() { if (isOnChat && !chatMounted) setChatMounted(true) }, [isOnChat, chatMounted]) + useEffect(() => { + if (!isOnChat) return + void refreshAgents() + }, [isOnChat, refreshAgents]) + + useEffect(() => { + if (!isOnChat || !selectedConversationId || agents.length === 0) return + const exists = agents.some((item) => item.id === selectedConversationId) + if (!exists) { + navigate('/chat', { replace: true }) + } + }, [isOnChat, selectedConversationId, agents, navigate]) + // Extract initialPrompt from URL search params when navigating to /chat?prompt=... const initialPrompt = isOnChat ? new URLSearchParams(location.search).get('prompt') ?? undefined @@ -172,6 +199,29 @@ export default function Layout() { navigate('/login') } + const openConversation = (id: string) => { + if (!id) return + navigate(`/chat?conversation=${encodeURIComponent(id)}`) + } + + const handleCreateConversation = async () => { + setConversationError(null) + setIsCreatingConversation(true) + try { + const created = await createConversation() + if (!created?.id) { + setConversationError('Failed to create session') + return + } + openConversation(created.id) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + setConversationError(message) + } finally { + setIsCreatingConversation(false) + } + } + return (
@@ -304,7 +354,56 @@ export default function Layout() {
{chatMounted && (
- +
+
+ Session + {activeConversationId ? ( + + + } + > + {shortConversationId(activeConversationId)} + + + {agents.length === 0 ? ( + + No sessions available + + ) : ( + agents.map((item) => ( + openConversation(item.id)}> + {item.id} + {item.id === activeConversationId && Current} + + )) + )} + + + ) : ( + Initializing... + )} +
+ +
+ + {conversationError && ( +
+ {conversationError} +
+ )} + +
)} diff --git a/apps/desktop/src/renderer/src/pages/onboarding/components/connect-step.tsx b/apps/desktop/src/renderer/src/pages/onboarding/components/connect-step.tsx index 5ad004ed..a66508c1 100644 --- a/apps/desktop/src/renderer/src/pages/onboarding/components/connect-step.tsx +++ b/apps/desktop/src/renderer/src/pages/onboarding/components/connect-step.tsx @@ -25,6 +25,8 @@ interface DeviceMeta { interface PendingConfirm { deviceId: string + agentId: string + conversationId: string meta?: DeviceMeta } @@ -42,9 +44,11 @@ export default function ConnectStep({ onNext, onBack }: ConnectStepProps) { // Listen for device confirm requests during onboarding useEffect(() => { - window.electronAPI?.hub.onDeviceConfirmRequest((deviceId: string, meta?: DeviceMeta) => { - setPending({ deviceId, meta }) - }) + window.electronAPI?.hub.onDeviceConfirmRequest( + (deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta) => { + setPending({ deviceId, agentId, conversationId, meta }) + }, + ) return () => { window.electronAPI?.hub.offDeviceConfirmRequest() } @@ -120,6 +124,7 @@ export default function ConnectStep({ onNext, onBack }: ConnectStepProps) { gateway={hubInfo?.url ?? 'http://localhost:3000'} hubId={hubInfo?.hubId ?? 'unknown'} agentId={primaryAgent?.id ?? 'unknown'} + conversationId={primaryAgent?.id ?? 'unknown'} expirySeconds={30} size={180} /> diff --git a/apps/desktop/src/renderer/src/stores/hub.ts b/apps/desktop/src/renderer/src/stores/hub.ts index 841ab25b..86369502 100644 --- a/apps/desktop/src/renderer/src/stores/hub.ts +++ b/apps/desktop/src/renderer/src/stores/hub.ts @@ -27,6 +27,8 @@ interface HubStore { init: () => Promise refresh: () => Promise reconnect: (url: string) => Promise<{ ok: boolean; error?: string }> + createConversation: (id?: string) => Promise + closeConversation: (id: string) => Promise } export const useHubStore = create()((set, get) => ({ @@ -45,7 +47,7 @@ export const useHubStore = create()((set, get) => ({ try { await window.electronAPI.hub.init() const info = await window.electronAPI.hub.info() - const agentList = await window.electronAPI.hub.listAgents() + const agentList = await window.electronAPI.hub.listConversations() set({ hubInfo: info as HubInfo, @@ -75,7 +77,7 @@ export const useHubStore = create()((set, get) => ({ try { const info = await window.electronAPI.hub.info() - const agentList = await window.electronAPI.hub.listAgents() + const agentList = await window.electronAPI.hub.listConversations() set({ hubInfo: info as HubInfo, @@ -101,6 +103,34 @@ export const useHubStore = create()((set, get) => ({ return { ok: false, error: message } } }, + + createConversation: async (id?: string) => { + try { + const result = await window.electronAPI.hub.createConversation(id) as { id?: string; closed?: boolean } + await get().refresh() + if (!result?.id) return null + return { + id: result.id, + closed: result.closed ?? false, + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ error: message }) + return null + } + }, + + closeConversation: async (id: string) => { + try { + const result = await window.electronAPI.hub.closeConversation(id) as { ok?: boolean } + await get().refresh() + return !!result?.ok + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ error: message }) + return false + } + }, })) // Selector helpers diff --git a/apps/gateway/migrations/telegram-users.sql b/apps/gateway/migrations/telegram-users.sql index 19f1a720..e67cc1bd 100644 --- a/apps/gateway/migrations/telegram-users.sql +++ b/apps/gateway/migrations/telegram-users.sql @@ -1,12 +1,14 @@ -- Telegram users table for Gateway -- Run this manually before starting the Gateway with Telegram enabled. +DROP TABLE IF EXISTS telegram_thread_routes; DROP TABLE IF EXISTS telegram_users; CREATE TABLE telegram_users ( telegram_user_id VARCHAR(64) PRIMARY KEY, hub_id VARCHAR(64) NOT NULL, agent_id VARCHAR(64) NOT NULL, + conversation_id VARCHAR(64) NULL, device_id VARCHAR(64) NOT NULL UNIQUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -15,3 +17,18 @@ CREATE TABLE telegram_users ( telegram_last_name VARCHAR(255), INDEX idx_device_id (device_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE telegram_thread_routes ( + telegram_user_id VARCHAR(64) NOT NULL, + chat_id VARCHAR(64) NOT NULL, + thread_id VARCHAR(64) NOT NULL DEFAULT '0', + conversation_id VARCHAR(64) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (telegram_user_id, chat_id, thread_id), + INDEX idx_conversation_id (conversation_id), + CONSTRAINT fk_telegram_thread_routes_user + FOREIGN KEY (telegram_user_id) + REFERENCES telegram_users(telegram_user_id) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/apps/gateway/public/index.html b/apps/gateway/public/index.html index 421ba06d..49333633 100644 --- a/apps/gateway/public/index.html +++ b/apps/gateway/public/index.html @@ -726,7 +726,7 @@ from: deviceId, to: targetDeviceId, action: 'message', - payload: { agentId: targetAgentId, content: text }, + payload: { agentId: targetAgentId, conversationId: targetAgentId, content: text }, }); appendMsg('self', text); diff --git a/apps/gateway/telegram/message-context-queue.test.ts b/apps/gateway/telegram/message-context-queue.test.ts index 16208389..45268373 100644 --- a/apps/gateway/telegram/message-context-queue.test.ts +++ b/apps/gateway/telegram/message-context-queue.test.ts @@ -5,25 +5,25 @@ import { MessageContextQueue } from "./message-context-queue.js"; describe("MessageContextQueue", () => { it("keeps the first context active while newer messages stay pending", () => { const queue = new MessageContextQueue(); - const deviceId = "device-1"; + const contextKey = "device-1:session-1"; - queue.enqueue(deviceId, { telegramChatId: 100, telegramMessageId: 1 }); - queue.enqueue(deviceId, { telegramChatId: 100, telegramMessageId: 2 }); + queue.enqueue(contextKey, { telegramChatId: 100, telegramMessageId: 1 }); + queue.enqueue(contextKey, { telegramChatId: 100, telegramMessageId: 2 }); - assert.deepEqual(queue.activate(deviceId), { + assert.deepEqual(queue.activate(contextKey), { telegramChatId: 100, telegramMessageId: 1, }); - assert.deepEqual(queue.peekForSend(deviceId), { + assert.deepEqual(queue.peekForSend(contextKey), { telegramChatId: 100, telegramMessageId: 1, }); - assert.deepEqual(queue.release(deviceId), { + assert.deepEqual(queue.release(contextKey), { telegramChatId: 100, telegramMessageId: 1, }); - assert.deepEqual(queue.peekForSend(deviceId), { + assert.deepEqual(queue.peekForSend(contextKey), { telegramChatId: 100, telegramMessageId: 2, }); @@ -31,17 +31,17 @@ describe("MessageContextQueue", () => { it("releases oldest pending context when a run errors before message_start", () => { const queue = new MessageContextQueue(); - const deviceId = "device-2"; + const contextKey = "device-2:session-2"; - queue.enqueue(deviceId, { telegramChatId: 200, telegramMessageId: 11 }); - queue.enqueue(deviceId, { telegramChatId: 200, telegramMessageId: 12 }); + queue.enqueue(contextKey, { telegramChatId: 200, telegramMessageId: 11 }); + queue.enqueue(contextKey, { telegramChatId: 200, telegramMessageId: 12 }); // No activate(): simulate agent_error before streaming starts - assert.deepEqual(queue.release(deviceId), { + assert.deepEqual(queue.release(contextKey), { telegramChatId: 200, telegramMessageId: 11, }); - assert.deepEqual(queue.peekForSend(deviceId), { + assert.deepEqual(queue.peekForSend(contextKey), { telegramChatId: 200, telegramMessageId: 12, }); @@ -49,28 +49,28 @@ describe("MessageContextQueue", () => { it("does not advance queue on repeated activate calls during one run", () => { const queue = new MessageContextQueue(); - const deviceId = "device-3"; + const contextKey = "device-3:session-3"; - queue.enqueue(deviceId, { telegramChatId: 300, telegramMessageId: 21 }); - queue.enqueue(deviceId, { telegramChatId: 300, telegramMessageId: 22 }); + queue.enqueue(contextKey, { telegramChatId: 300, telegramMessageId: 21 }); + queue.enqueue(contextKey, { telegramChatId: 300, telegramMessageId: 22 }); - assert.equal(queue.activate(deviceId)?.telegramMessageId, 21); - assert.equal(queue.activate(deviceId)?.telegramMessageId, 21); + assert.equal(queue.activate(contextKey)?.telegramMessageId, 21); + assert.equal(queue.activate(contextKey)?.telegramMessageId, 21); - assert.equal(queue.release(deviceId)?.telegramMessageId, 21); - assert.equal(queue.peekForSend(deviceId)?.telegramMessageId, 22); + assert.equal(queue.release(contextKey)?.telegramMessageId, 21); + assert.equal(queue.peekForSend(contextKey)?.telegramMessageId, 22); }); - it("isolates contexts by device", () => { + it("isolates contexts by context key", () => { const queue = new MessageContextQueue(); - queue.enqueue("a", { telegramChatId: 1, telegramMessageId: 1 }); - queue.enqueue("b", { telegramChatId: 2, telegramMessageId: 2 }); + queue.enqueue("a:session-1", { telegramChatId: 1, telegramMessageId: 1 }); + queue.enqueue("a:session-2", { telegramChatId: 2, telegramMessageId: 2 }); - assert.equal(queue.activate("a")?.telegramMessageId, 1); - assert.equal(queue.peekForSend("b")?.telegramMessageId, 2); - assert.equal(queue.release("a")?.telegramMessageId, 1); - assert.equal(queue.peekForSend("a"), undefined); - assert.equal(queue.peekForSend("b")?.telegramMessageId, 2); + assert.equal(queue.activate("a:session-1")?.telegramMessageId, 1); + assert.equal(queue.peekForSend("a:session-2")?.telegramMessageId, 2); + assert.equal(queue.release("a:session-1")?.telegramMessageId, 1); + assert.equal(queue.peekForSend("a:session-1"), undefined); + assert.equal(queue.peekForSend("a:session-2")?.telegramMessageId, 2); }); }); diff --git a/apps/gateway/telegram/message-context-queue.ts b/apps/gateway/telegram/message-context-queue.ts index fd630bb7..323a477c 100644 --- a/apps/gateway/telegram/message-context-queue.ts +++ b/apps/gateway/telegram/message-context-queue.ts @@ -14,32 +14,32 @@ export class MessageContextQueue { private readonly pending = new Map(); private readonly active = new Map(); - enqueue(deviceId: string, context: MessageContext): void { - const queue = this.pending.get(deviceId); + enqueue(contextKey: string, context: MessageContext): void { + const queue = this.pending.get(contextKey); if (queue) { queue.push(context); return; } - this.pending.set(deviceId, [context]); + this.pending.set(contextKey, [context]); } /** * Bind the next pending context to the active run for this device. * If a run is already active, keep it unchanged. */ - activate(deviceId: string): MessageContext | undefined { - const current = this.active.get(deviceId); + activate(contextKey: string): MessageContext | undefined { + const current = this.active.get(contextKey); if (current) return current; - const queue = this.pending.get(deviceId); + const queue = this.pending.get(contextKey); if (!queue || queue.length === 0) return undefined; const next = queue.shift(); if (queue.length === 0) { - this.pending.delete(deviceId); + this.pending.delete(contextKey); } if (next) { - this.active.set(deviceId, next); + this.active.set(contextKey, next); } return next; } @@ -48,11 +48,11 @@ export class MessageContextQueue { * Get the context to use for outbound sends. * Prefer active run context; otherwise fall back to oldest pending. */ - peekForSend(deviceId: string): MessageContext | undefined { - const current = this.active.get(deviceId); + peekForSend(contextKey: string): MessageContext | undefined { + const current = this.active.get(contextKey); if (current) return current; - const queue = this.pending.get(deviceId); + const queue = this.pending.get(contextKey); return queue?.[0]; } @@ -60,19 +60,19 @@ export class MessageContextQueue { * Release one context after a run completes/errors. * Prefer active context; if none active, release oldest pending. */ - release(deviceId: string): MessageContext | undefined { - const current = this.active.get(deviceId); + release(contextKey: string): MessageContext | undefined { + const current = this.active.get(contextKey); if (current) { - this.active.delete(deviceId); + this.active.delete(contextKey); return current; } - const queue = this.pending.get(deviceId); + const queue = this.pending.get(contextKey); if (!queue || queue.length === 0) return undefined; const next = queue.shift(); if (queue.length === 0) { - this.pending.delete(deviceId); + this.pending.delete(contextKey); } return next; } diff --git a/apps/gateway/telegram/telegram-user.store.ts b/apps/gateway/telegram/telegram-user.store.ts index 3d0a1f13..66012e03 100644 --- a/apps/gateway/telegram/telegram-user.store.ts +++ b/apps/gateway/telegram/telegram-user.store.ts @@ -18,6 +18,7 @@ interface TelegramUserRow extends RowDataPacket { telegram_user_id: string; hub_id: string; agent_id: string; + conversation_id?: string | null; device_id: string; created_at: Date; updated_at: Date; @@ -26,18 +27,51 @@ interface TelegramUserRow extends RowDataPacket { telegram_last_name: string | null; } +interface TelegramThreadRouteRow extends RowDataPacket { + telegram_user_id: string; + chat_id: string; + thread_id: string; + conversation_id: string; +} + +interface LocalStoreFileShape { + users?: Record; + threadRoutes?: Record>; +} + const LOCAL_STORE_DIR = join(DATA_DIR, "gateway"); const LOCAL_STORE_PATH = join(LOCAL_STORE_DIR, "telegram-users.json"); +const DEFAULT_THREAD_ID = "0"; @Injectable() export class TelegramUserStore { private readonly logger = new Logger(TelegramUserStore.name); /** Local file-backed store, keyed by telegramUserId */ private localStore = new Map(); + /** Local per-thread conversation routes, keyed by telegramUserId -> routeKey(chatId:threadId) -> conversationId */ + private localThreadRoutes = new Map>(); private localStoreLoaded = false; + private warnedMissingThreadTable = false; constructor(@Inject(DatabaseService) private readonly db: DatabaseService) {} + private hasMissingConversationColumnError(err: unknown): boolean { + if (!err || typeof err !== "object") return false; + const maybe = err as { code?: string; message?: string }; + return maybe.code === "ER_BAD_FIELD_ERROR" && (maybe.message ?? "").includes("conversation_id"); + } + + private hasMissingThreadRoutesTableError(err: unknown): boolean { + if (!err || typeof err !== "object") return false; + const maybe = err as { code?: string; message?: string }; + if (maybe.code === "ER_NO_SUCH_TABLE") return true; + return maybe.code === "ER_BAD_FIELD_ERROR" && (maybe.message ?? "").includes("telegram_thread_routes"); + } + + private getThreadRouteKey(chatId: string, threadId?: string): string { + return `${chatId}:${threadId?.trim() || DEFAULT_THREAD_ID}`; + } + /** Find user by Telegram user ID */ async findByTelegramUserId(telegramUserId: string): Promise { if (!this.db.isAvailable()) { @@ -73,6 +107,76 @@ export class TelegramUserStore { return this.rowToUser(rows[0]!); } + async getThreadConversation( + telegramUserId: string, + chatId: string, + threadId?: string, + ): Promise { + const routeKey = this.getThreadRouteKey(chatId, threadId); + + if (!this.db.isAvailable()) { + await this.ensureLocalStoreLoaded(); + const routes = this.localThreadRoutes.get(telegramUserId); + return routes?.get(routeKey) ?? null; + } + + try { + const rows = await this.db.query( + `SELECT conversation_id + FROM telegram_thread_routes + WHERE telegram_user_id = ? AND chat_id = ? AND thread_id = ? + LIMIT 1`, + [telegramUserId, chatId, threadId?.trim() || DEFAULT_THREAD_ID], + ); + return rows[0]?.conversation_id ?? null; + } catch (err) { + if (!this.hasMissingThreadRoutesTableError(err)) throw err; + if (!this.warnedMissingThreadTable) { + this.warnedMissingThreadTable = true; + this.logger.warn( + "telegram_thread_routes table missing; per-thread conversation routing will fall back to default conversation", + ); + } + return null; + } + } + + async setThreadConversation( + telegramUserId: string, + chatId: string, + conversationId: string, + threadId?: string, + ): Promise { + const routeKey = this.getThreadRouteKey(chatId, threadId); + + if (!this.db.isAvailable()) { + await this.ensureLocalStoreLoaded(); + const routes = this.localThreadRoutes.get(telegramUserId) ?? new Map(); + routes.set(routeKey, conversationId); + this.localThreadRoutes.set(telegramUserId, routes); + await this.saveLocalStore(); + return; + } + + try { + await this.db.execute( + `INSERT INTO telegram_thread_routes ( + telegram_user_id, chat_id, thread_id, conversation_id + ) VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE conversation_id = VALUES(conversation_id), updated_at = CURRENT_TIMESTAMP`, + [telegramUserId, chatId, threadId?.trim() || DEFAULT_THREAD_ID, conversationId], + ); + } catch (err) { + if (!this.hasMissingThreadRoutesTableError(err)) throw err; + if (!this.warnedMissingThreadTable) { + this.warnedMissingThreadTable = true; + this.logger.warn( + "telegram_thread_routes table missing; per-thread conversation routing will not persist in DB", + ); + } + } + } + /** Create or update a Telegram user */ async upsert(data: TelegramUserCreate): Promise { if (!this.db.isAvailable()) { @@ -84,25 +188,51 @@ export class TelegramUserStore { if (existing) { // Update existing user — also update device_id if provided - await this.db.execute( - `UPDATE telegram_users SET - hub_id = ?, - agent_id = ?, - device_id = ?, - telegram_username = ?, - telegram_first_name = ?, - telegram_last_name = ? - WHERE telegram_user_id = ?`, - [ - data.hubId, - data.agentId, - data.deviceId ?? existing.deviceId, - data.telegramUsername ?? null, - data.telegramFirstName ?? null, - data.telegramLastName ?? null, - data.telegramUserId, - ] - ); + const nextConversationId = data.conversationId ?? existing.conversationId ?? existing.agentId; + try { + await this.db.execute( + `UPDATE telegram_users SET + hub_id = ?, + agent_id = ?, + conversation_id = ?, + device_id = ?, + telegram_username = ?, + telegram_first_name = ?, + telegram_last_name = ? + WHERE telegram_user_id = ?`, + [ + data.hubId, + data.agentId, + nextConversationId, + data.deviceId ?? existing.deviceId, + data.telegramUsername ?? null, + data.telegramFirstName ?? null, + data.telegramLastName ?? null, + data.telegramUserId, + ] + ); + } catch (err) { + if (!this.hasMissingConversationColumnError(err)) throw err; + await this.db.execute( + `UPDATE telegram_users SET + hub_id = ?, + agent_id = ?, + device_id = ?, + telegram_username = ?, + telegram_first_name = ?, + telegram_last_name = ? + WHERE telegram_user_id = ?`, + [ + data.hubId, + data.agentId, + data.deviceId ?? existing.deviceId, + data.telegramUsername ?? null, + data.telegramFirstName ?? null, + data.telegramLastName ?? null, + data.telegramUserId, + ] + ); + } const updated = await this.findByTelegramUserId(data.telegramUserId); return updated!; @@ -110,22 +240,43 @@ export class TelegramUserStore { // Create new user with provided or generated device ID const deviceId = data.deviceId ?? `tg-${generateEncryptedId()}`; + const conversationId = data.conversationId ?? data.agentId; - await this.db.execute( - `INSERT INTO telegram_users ( - telegram_user_id, hub_id, agent_id, device_id, - telegram_username, telegram_first_name, telegram_last_name - ) VALUES (?, ?, ?, ?, ?, ?, ?)`, - [ - data.telegramUserId, - data.hubId, - data.agentId, - deviceId, - data.telegramUsername ?? null, - data.telegramFirstName ?? null, - data.telegramLastName ?? null, - ] - ); + try { + await this.db.execute( + `INSERT INTO telegram_users ( + telegram_user_id, hub_id, agent_id, conversation_id, device_id, + telegram_username, telegram_first_name, telegram_last_name + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + data.telegramUserId, + data.hubId, + data.agentId, + conversationId, + deviceId, + data.telegramUsername ?? null, + data.telegramFirstName ?? null, + data.telegramLastName ?? null, + ] + ); + } catch (err) { + if (!this.hasMissingConversationColumnError(err)) throw err; + await this.db.execute( + `INSERT INTO telegram_users ( + telegram_user_id, hub_id, agent_id, device_id, + telegram_username, telegram_first_name, telegram_last_name + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + data.telegramUserId, + data.hubId, + data.agentId, + deviceId, + data.telegramUsername ?? null, + data.telegramFirstName ?? null, + data.telegramLastName ?? null, + ] + ); + } const created = await this.findByTelegramUserId(data.telegramUserId); return created!; @@ -144,13 +295,28 @@ export class TelegramUserStore { try { const data = await readFile(LOCAL_STORE_PATH, "utf-8"); - const records = JSON.parse(data) as Record; + const parsed = JSON.parse(data) as LocalStoreFileShape | Record; + const records = (parsed as LocalStoreFileShape).users + ?? (parsed as Record); + const threadRoutes = (parsed as LocalStoreFileShape).threadRoutes ?? {}; + for (const [key, user] of Object.entries(records)) { // Restore Date objects from JSON strings user.createdAt = new Date(user.createdAt); user.updatedAt = new Date(user.updatedAt); this.localStore.set(key, user); } + + for (const [userId, routes] of Object.entries(threadRoutes)) { + const map = new Map(); + for (const [routeKey, conversationId] of Object.entries(routes)) { + if (!routeKey || !conversationId) continue; + map.set(routeKey, conversationId); + } + if (map.size > 0) { + this.localThreadRoutes.set(userId, map); + } + } this.logger.log(`Loaded ${this.localStore.size} Telegram user(s) from ${LOCAL_STORE_PATH}`); } catch { // File doesn't exist or is invalid — start fresh @@ -163,8 +329,16 @@ export class TelegramUserStore { for (const [key, user] of this.localStore) { obj[key] = user; } + const threadRoutes: Record> = {}; + for (const [userId, routes] of this.localThreadRoutes) { + threadRoutes[userId] = Object.fromEntries(routes.entries()); + } await mkdir(LOCAL_STORE_DIR, { recursive: true }); - await writeFile(LOCAL_STORE_PATH, JSON.stringify(obj, null, 2), "utf-8"); + await writeFile( + LOCAL_STORE_PATH, + JSON.stringify({ users: obj, threadRoutes }, null, 2), + "utf-8", + ); } /** Upsert to local file store */ @@ -178,6 +352,7 @@ export class TelegramUserStore { telegramUserId: data.telegramUserId, hubId: data.hubId, agentId: data.agentId, + conversationId: data.conversationId ?? existing?.conversationId ?? data.agentId, deviceId: data.deviceId ?? existing?.deviceId ?? `tg-${generateEncryptedId()}`, createdAt: existing?.createdAt ?? now, updatedAt: now, @@ -198,6 +373,7 @@ export class TelegramUserStore { telegramUserId: row.telegram_user_id, hubId: row.hub_id, agentId: row.agent_id, + conversationId: row.conversation_id ?? undefined, deviceId: row.device_id, createdAt: row.created_at, updatedAt: row.updated_at, diff --git a/apps/gateway/telegram/telegram.controller.ts b/apps/gateway/telegram/telegram.controller.ts index 22bdc6b4..c65ffabb 100644 --- a/apps/gateway/telegram/telegram.controller.ts +++ b/apps/gateway/telegram/telegram.controller.ts @@ -29,7 +29,14 @@ export class TelegramController { @Post("connect-code") async createConnectCode( - @Body() body: { gateway: string; hubId: string; agentId: string; token: string; expires: number }, + @Body() body: { + gateway: string; + hubId: string; + agentId: string; + conversationId: string; + token: string; + expires: number; + }, ): Promise<{ code: string; botUsername: string }> { if (!this.telegramService.isConfigured()) { throw new HttpException("Telegram bot not configured", HttpStatus.SERVICE_UNAVAILABLE); @@ -45,6 +52,7 @@ export class TelegramController { gateway: body.gateway, hubId: body.hubId, agentId: body.agentId, + conversationId: body.conversationId, token: body.token, expires: body.expires, }; diff --git a/apps/gateway/telegram/telegram.service.ts b/apps/gateway/telegram/telegram.service.ts index 3fa3cc8e..ceed8ebc 100644 --- a/apps/gateway/telegram/telegram.service.ts +++ b/apps/gateway/telegram/telegram.service.ts @@ -92,6 +92,14 @@ interface GenerateChannelWelcomeResult { text: string; } +interface ListConversationsResult { + conversations: Array<{ id: string; closed: boolean }>; +} + +interface CreateConversationResult { + id: string; +} + // ── Constants ── const VERIFY_TIMEOUT_MS = 30_000; @@ -195,6 +203,56 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { @Inject(TelegramUserStore) private readonly userStore: TelegramUserStore, ) {} + private makeConversationContextKey(deviceId: string, conversationId?: string): string { + return `${deviceId}:${conversationId ?? "default"}`; + } + + private getThreadRoute(ctx: Context): { chatId: string; threadId: string } | null { + const chatId = ctx.chat?.id; + if (chatId === undefined || chatId === null) return null; + const maybeMsg = ctx.message as { message_thread_id?: number } | undefined; + const threadId = maybeMsg?.message_thread_id != null ? String(maybeMsg.message_thread_id) : "0"; + return { chatId: String(chatId), threadId }; + } + + private async resolveConversationForContext(user: TelegramUser, ctx: Context): Promise { + const fallbackConversationId = user.conversationId ?? user.agentId; + const threadRoute = this.getThreadRoute(ctx); + if (!threadRoute) return fallbackConversationId; + + const mappedConversationId = await this.userStore.getThreadConversation( + user.telegramUserId, + threadRoute.chatId, + threadRoute.threadId, + ); + if (mappedConversationId) { + return mappedConversationId; + } + + await this.userStore.setThreadConversation( + user.telegramUserId, + threadRoute.chatId, + fallbackConversationId, + threadRoute.threadId, + ); + return fallbackConversationId; + } + + private async bindConversationToContext( + user: TelegramUser, + ctx: Context, + conversationId: string, + ): Promise { + const threadRoute = this.getThreadRoute(ctx); + if (!threadRoute) return; + await this.userStore.setThreadConversation( + user.telegramUserId, + threadRoute.chatId, + conversationId, + threadRoute.threadId, + ); + } + // ── Lifecycle ── async onModuleInit(): Promise { @@ -355,6 +413,21 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { await ctx.reply(text, { parse_mode: "HTML", reply_markup: keyboard }); }); + this.bot.command("new", async (ctx) => { + if (!this.isPrivateChat(ctx)) return; + await this.handleNewConversationCommand(ctx); + }); + + this.bot.command("session", async (ctx) => { + if (!this.isPrivateChat(ctx)) return; + await this.handleSessionCommand(ctx, ctx.match?.trim() ?? ""); + }); + + this.bot.command("sessions", async (ctx) => { + if (!this.isPrivateChat(ctx)) return; + await this.handleSessionsCommand(ctx); + }); + // Inline button callback queries this.bot.callbackQuery(CB_HOW_TO_CONNECT, async (ctx) => { await ctx.answerCallbackQuery(); @@ -529,7 +602,8 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { const text = `Welcome back!\n\n` + `${statusEmoji} Status: ${statusText}\n` + - `Agent: ${user.agentId}\n\n` + + `Agent: ${user.agentId}\n` + + `Conversation: ${user.conversationId ?? user.agentId}\n\n` + (online ? `Your agent is ready. Just send a message to start chatting.` : `Your Hub is offline. Make sure the Multica Desktop app is running.`); @@ -563,7 +637,8 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { `Connection Status\n\n` + `${statusEmoji} ${statusLabel}\n\n` + `Hub: ${user.hubId}\n` + - `Agent: ${user.agentId}\n\n` + + `Agent: ${user.agentId}\n` + + `Conversation: ${user.conversationId ?? user.agentId}\n\n` + (online ? `Your Hub is online and ready to receive messages.` : `Your Hub is offline. Make sure the Multica Desktop app is running.`); @@ -585,6 +660,9 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { `Commands\n` + ` /start \u2014 Connect your account or see welcome\n` + ` /status \u2014 Check connection status\n` + + ` /new \u2014 Start a new isolated conversation\n` + + ` /session [id] \u2014 Show or switch current conversation\n` + + ` /sessions \u2014 List available conversations\n` + ` /help \u2014 Show this message\n\n` + `How to connect\n` + ` 1. Open Multica Desktop app\n` + @@ -610,6 +688,9 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { await this.bot.api.setMyCommands([ { command: "start", description: "Connect or show welcome" }, { command: "status", description: "Check connection status" }, + { command: "new", description: "Create a new conversation" }, + { command: "session", description: "Show/switch current conversation" }, + { command: "sessions", description: "List conversations" }, { command: "help", description: "Show help and instructions" }, ]); @@ -664,7 +745,6 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { if (user) { // ACK: 👀 reaction on the original message await this.addReaction(msg.chat.id, msg.message_id, "👀"); - this.storeMessageContext(user.deviceId, msg.chat.id, msg.message_id); // Prepend reply context if user is replying to a specific message const replyContext = extractReplyContext(ctx); const content = replyContext ? `${replyContext}\n${text}` : text; @@ -677,6 +757,184 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { await ctx.reply(welcome.text, { parse_mode: "HTML", reply_markup: welcome.keyboard }); } + private async createConversationViaRpc(deviceId: string, hubId: string, agentId?: string): Promise<{ id: string }> { + const created = await this.sendRpc<{ agentId?: string }, CreateConversationResult>( + deviceId, + hubId, + "createConversation", + agentId ? { agentId } : {}, + VERIFY_TIMEOUT_MS, + "Create conversation request timed out", + ); + return { id: created.id }; + } + + private async listConversationsViaRpc(deviceId: string, hubId: string): Promise> { + const result = await this.sendRpc, ListConversationsResult>( + deviceId, + hubId, + "listConversations", + {}, + VERIFY_TIMEOUT_MS, + "List conversations request timed out", + ); + return result.conversations; + } + + private async handleNewConversationCommand(ctx: Context): Promise { + const telegramUserId = String(ctx.from?.id); + const user = await this.userStore.findByTelegramUserId(telegramUserId); + if (!user) { + await ctx.reply("You are not connected yet. Use /start and connect from Desktop first."); + return; + } + + if (!this.eventsGateway.isDeviceRegistered(user.hubId)) { + await ctx.reply( + "Your Hub is currently offline.\n\n" + + "Make sure the Multica Desktop app is running and connected to the Gateway.", + ); + return; + } + + if (!this.eventsGateway.isDeviceRegistered(user.deviceId)) { + this.registerVirtualDeviceForUser(user.deviceId, user.telegramUserId); + } + + try { + const created = await this.createConversationViaRpc(user.deviceId, user.hubId, user.agentId); + + await this.userStore.upsert({ + telegramUserId: user.telegramUserId, + hubId: user.hubId, + agentId: user.agentId, + conversationId: created.id, + deviceId: user.deviceId, + telegramUsername: user.telegramUsername, + telegramFirstName: user.telegramFirstName, + telegramLastName: user.telegramLastName, + }); + await this.bindConversationToContext(user, ctx, created.id); + + await ctx.reply( + `\u2705 New conversation created\n\n` + + `Conversation: ${created.id}\n\n` + + `All next messages in this Telegram thread will use this conversation.`, + { parse_mode: "HTML" }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await ctx.reply(`Failed to create conversation: ${message}`); + } + } + + private async handleSessionsCommand(ctx: Context): Promise { + const telegramUserId = String(ctx.from?.id); + const user = await this.userStore.findByTelegramUserId(telegramUserId); + if (!user) { + await ctx.reply("You are not connected yet. Use /start and connect from Desktop first."); + return; + } + + if (!this.eventsGateway.isDeviceRegistered(user.hubId)) { + await ctx.reply("Your Hub is offline. Open Desktop and reconnect, then try again."); + return; + } + + if (!this.eventsGateway.isDeviceRegistered(user.deviceId)) { + this.registerVirtualDeviceForUser(user.deviceId, user.telegramUserId); + } + + try { + const conversations = await this.listConversationsViaRpc(user.deviceId, user.hubId); + const conversationIds = conversations.filter((item) => !item.closed).map((item) => item.id); + if (conversationIds.length === 0) { + await ctx.reply("No conversations found."); + return; + } + + const current = await this.resolveConversationForContext(user, ctx); + const lines = conversationIds.slice(0, 20).map((id) => { + const marker = id === current ? "\u2022 current" : ""; + return `${id}${marker ? ` ${marker}` : ""}`; + }); + const extra = conversationIds.length > 20 ? `\n...and ${conversationIds.length - 20} more` : ""; + + await ctx.reply( + `Available conversations\n\n` + + `${lines.join("\n")}${extra}\n\n` + + `Use /session <id> to switch.`, + { parse_mode: "HTML" }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await ctx.reply(`Failed to load conversations: ${message}`); + } + } + + private async handleSessionCommand(ctx: Context, input: string): Promise { + const telegramUserId = String(ctx.from?.id); + const user = await this.userStore.findByTelegramUserId(telegramUserId); + if (!user) { + await ctx.reply("You are not connected yet. Use /start and connect from Desktop first."); + return; + } + + const target = input.trim(); + const current = await this.resolveConversationForContext(user, ctx); + if (!target) { + await ctx.reply( + `Current conversation\n\n` + + `${current}\n\n` + + `Use /session <id> to switch.`, + { parse_mode: "HTML" }, + ); + return; + } + + if (!this.eventsGateway.isDeviceRegistered(user.hubId)) { + await ctx.reply("Your Hub is offline. Open Desktop and reconnect, then try again."); + return; + } + + if (!this.eventsGateway.isDeviceRegistered(user.deviceId)) { + this.registerVirtualDeviceForUser(user.deviceId, user.telegramUserId); + } + + try { + const conversations = await this.listConversationsViaRpc(user.deviceId, user.hubId); + const exists = conversations.some((item) => item.id === target && !item.closed); + if (!exists) { + await ctx.reply( + `Conversation not found: ${target}\n\nUse /sessions to list available conversations.`, + { parse_mode: "HTML" }, + ); + return; + } + + await this.userStore.upsert({ + telegramUserId: user.telegramUserId, + hubId: user.hubId, + agentId: user.agentId, + conversationId: target, + deviceId: user.deviceId, + telegramUsername: user.telegramUsername, + telegramFirstName: user.telegramFirstName, + telegramLastName: user.telegramLastName, + }); + await this.bindConversationToContext(user, ctx, target); + + await ctx.reply( + `\u2705 Conversation switched\n\n` + + `Current conversation: ${target}`, + { parse_mode: "HTML" }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await ctx.reply(`Failed to switch conversation: ${message}`); + } + } + // ── Inbound: media messages ── private async handleMediaMessage(ctx: Context, media: MediaAttachment): Promise { @@ -704,7 +962,6 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { // ACK: 👀 reaction await this.addReaction(msg.chat.id, msg.message_id, "👀"); - this.storeMessageContext(user.deviceId, msg.chat.id, msg.message_id); // Process media → text description (async, may take a few seconds) const processedText = await this.processMedia({ ...media, caption: caption ?? undefined }); @@ -809,7 +1066,7 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { * Send text to a Telegram user/group by deviceId. * Applies Markdown → HTML formatting, text chunking, and reply-to. */ - async sendToTelegram(deviceId: string, text: string): Promise { + async sendToTelegram(deviceId: string, text: string, conversationId?: string): Promise { if (!this.bot) return; const user = await this.userStore.findByDeviceId(deviceId); @@ -819,7 +1076,8 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { } // Use chatId from message context (supports groups); fall back to user ID (private chat) - const context = this.messageContexts.peekForSend(deviceId); + const contextKey = this.makeConversationContextKey(deviceId, conversationId); + const context = this.messageContexts.peekForSend(contextKey); const chatId = context?.telegramChatId ?? Number(user.telegramUserId); const chunks = chunkText(text); @@ -872,13 +1130,15 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { type: string, caption?: string, filename?: string, + conversationId?: string, ): Promise { if (!this.bot) return; const user = await this.userStore.findByDeviceId(deviceId); if (!user) return; - const context = this.messageContexts.peekForSend(deviceId); + const contextKey = this.makeConversationContextKey(deviceId, conversationId); + const context = this.messageContexts.peekForSend(contextKey); const chatId = context?.telegramChatId ?? Number(user.telegramUserId); const inputFile = new InputFile(data, filename); @@ -964,10 +1224,10 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { // ── Typing indicators ── - private startTyping(deviceId: string): void { - if (this.typingTimers.has(deviceId)) return; + private startTyping(contextKey: string): void { + if (this.typingTimers.has(contextKey)) return; - const context = this.messageContexts.peekForSend(deviceId); + const context = this.messageContexts.peekForSend(contextKey); if (!context) return; const chatId = context.telegramChatId; @@ -976,41 +1236,41 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { }; send(); const interval = setInterval(send, 5000); - this.typingTimers.set(deviceId, interval); + this.typingTimers.set(contextKey, interval); // Safety timeout: auto-stop if no message_end/agent_error arrives setTimeout(() => { - if (this.typingTimers.get(deviceId) === interval) { - this.stopTyping(deviceId); + if (this.typingTimers.get(contextKey) === interval) { + this.stopTyping(contextKey); } }, TYPING_TIMEOUT_MS); } - private stopTyping(deviceId: string): void { - const timer = this.typingTimers.get(deviceId); + private stopTyping(contextKey: string): void { + const timer = this.typingTimers.get(contextKey); if (timer) { clearInterval(timer); - this.typingTimers.delete(deviceId); + this.typingTimers.delete(contextKey); } } // ── Message context tracking ── - private storeMessageContext(deviceId: string, chatId: number, messageId: number): void { - this.messageContexts.enqueue(deviceId, { + private storeMessageContext(contextKey: string, chatId: number, messageId: number): void { + this.messageContexts.enqueue(contextKey, { telegramChatId: chatId, telegramMessageId: messageId, }); } /** Bind the oldest pending context to the currently running agent response. */ - private activateMessageContext(deviceId: string): MessageContext | undefined { - return this.messageContexts.activate(deviceId); + private activateMessageContext(contextKey: string): MessageContext | undefined { + return this.messageContexts.activate(contextKey); } /** Remove context and 👀 reaction for a device after response is sent */ - private async clearMessageContext(deviceId: string): Promise { - const context = this.messageContexts.release(deviceId); + private async clearMessageContext(contextKey: string): Promise { + const context = this.messageContexts.release(contextKey); if (context) { await this.removeReaction(context.telegramChatId, context.telegramMessageId); } @@ -1073,26 +1333,38 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { ? `Telegram @${msg.from.username}` : `Telegram ${msg?.from?.first_name ?? telegramUserId}`, }); + const conversationId = connectionInfo.conversationId ?? result.conversationId; // 5. Save to DB await this.userStore.upsert({ telegramUserId, hubId: connectionInfo.hubId, agentId: connectionInfo.agentId, + conversationId, deviceId, telegramUsername: msg?.from?.username, telegramFirstName: msg?.from?.first_name, telegramLastName: msg?.from?.last_name, }); + const threadRoute = this.getThreadRoute(ctx); + if (threadRoute) { + await this.userStore.setThreadConversation( + telegramUserId, + threadRoute.chatId, + conversationId, + threadRoute.threadId, + ); + } const successKeyboard = new InlineKeyboard() .text("Check status", CB_CHECK_STATUS) .text("Help", CB_SHOW_HELP); await ctx.reply( - `\u2705 Connected successfully!\n\n` + + `\u2705 Connected successfully!\n\n` + `Hub: ${result.hubId}\n` + - `Agent: ${result.agentId}\n\n` + + `Agent: ${result.agentId}\n` + + `Conversation: ${conversationId}\n\n` + `You can now send messages to interact with your agent.`, { parse_mode: "HTML", reply_markup: successKeyboard }, ); @@ -1298,13 +1570,19 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { } // Send message to Hub + const conversationId = await this.resolveConversationForContext(user, ctx); + const msg = ctx.message; + if (msg) { + const contextKey = this.makeConversationContextKey(user.deviceId, conversationId); + this.storeMessageContext(contextKey, msg.chat.id, msg.message_id); + } const message: RoutedMessage = { id: uuidv7(), uid: null, from: user.deviceId, to: user.hubId, action: "message", - payload: { agentId: user.agentId, content: text }, + payload: { agentId: user.agentId, conversationId, content: text }, }; const sent = this.eventsGateway.routeFromVirtualDevice(message); @@ -1314,7 +1592,7 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { } this.logger.debug( - `Routed message to Hub: deviceId=${user.deviceId}, hubId=${user.hubId}, agentId=${user.agentId}`, + `Routed message to Hub: deviceId=${user.deviceId}, hubId=${user.hubId}, agentId=${user.agentId}, conversationId=${conversationId}`, ); } @@ -1355,11 +1633,13 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { const streamPayload = msg.payload as StreamPayload; const event = streamPayload?.event; if (!event || !("type" in event)) return; + const conversationId = streamPayload?.conversationId; + const contextKey = this.makeConversationContextKey(deviceId, conversationId); // Start typing when LLM begins generating if (event.type === "message_start") { - this.activateMessageContext(deviceId); - this.startTyping(deviceId); + this.activateMessageContext(contextKey); + this.startTyping(contextKey); return; } @@ -1377,9 +1657,9 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { .map((c) => c.text!) .join("") ?? ""; if (narration) { - void this.sendToTelegram(deviceId, narration).then(() => { + void this.sendToTelegram(deviceId, narration, conversationId).then(() => { // Re-send typing indicator — Telegram clears it when a message is sent - const ctx = this.messageContexts.peekForSend(deviceId); + const ctx = this.messageContexts.peekForSend(contextKey); if (ctx) { void this.bot?.api.sendChatAction(ctx.telegramChatId, "typing").catch(() => {}); } @@ -1388,15 +1668,15 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { return; } - this.stopTyping(deviceId); + this.stopTyping(contextKey); if (agentMsg?.content) { const textContent = agentMsg.content .filter((c) => c.type === "text" && c.text) .map((c) => c.text!) .join(""); if (textContent) { - void this.sendToTelegram(deviceId, textContent).then(() => { - void this.clearMessageContext(deviceId); + void this.sendToTelegram(deviceId, textContent, conversationId).then(() => { + void this.clearMessageContext(contextKey); }); } } @@ -1405,8 +1685,8 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { // Stop typing on error if (event.type === "agent_error") { - this.stopTyping(deviceId); - void this.clearMessageContext(deviceId); + this.stopTyping(contextKey); + void this.clearMessageContext(contextKey); return; } @@ -1420,25 +1700,36 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { type?: string; caption?: string; filename?: string; + conversationId?: string; }; if (payload?.data) { + const conversationId = payload.conversationId; + const contextKey = this.makeConversationContextKey(deviceId, conversationId); void this.sendFileToTelegram( deviceId, Buffer.from(payload.data, "base64"), payload.type ?? "document", payload.caption, payload.filename, + conversationId, ); + this.activateMessageContext(contextKey); } return; } // Regular message (e.g., "message" action from Hub) if (msg.action === "message") { - const payload = msg.payload as { content?: string; agentId?: string }; + const payload = msg.payload as { + content?: string; + agentId?: string; + conversationId?: string; + }; if (payload?.content) { - void this.sendToTelegram(deviceId, payload.content).then(() => { - void this.clearMessageContext(deviceId); + const conversationId = payload.conversationId; + const contextKey = this.makeConversationContextKey(deviceId, conversationId); + void this.sendToTelegram(deviceId, payload.content, conversationId).then(() => { + void this.clearMessageContext(contextKey); }); } return; @@ -1446,11 +1737,17 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { // Error messages if (msg.action === "error") { - this.stopTyping(deviceId); - void this.clearMessageContext(deviceId); - const payload = msg.payload as { message?: string; code?: string }; + const payload = msg.payload as { + message?: string; + code?: string; + conversationId?: string; + }; + const conversationId = payload.conversationId; + const contextKey = this.makeConversationContextKey(deviceId, conversationId); + this.stopTyping(contextKey); + void this.clearMessageContext(contextKey); if (payload?.message) { - void this.sendToTelegram(deviceId, `Error: ${payload.message}`); + void this.sendToTelegram(deviceId, `Error: ${payload.message}`, conversationId); } } }, diff --git a/apps/gateway/telegram/types.ts b/apps/gateway/telegram/types.ts index c4bd87fc..6f6db717 100644 --- a/apps/gateway/telegram/types.ts +++ b/apps/gateway/telegram/types.ts @@ -7,6 +7,8 @@ export interface TelegramUser { telegramUserId: string; hubId: string; agentId: string; + /** Default session fallback when no per-thread route is stored. */ + conversationId?: string | undefined; deviceId: string; createdAt: Date; updatedAt: Date; @@ -20,6 +22,7 @@ export interface TelegramUserCreate { telegramUserId: string; hubId: string; agentId: string; + conversationId?: string | undefined; deviceId?: string; telegramUsername?: string | undefined; telegramFirstName?: string | undefined; diff --git a/apps/server/app.controller.ts b/apps/server/app.controller.ts index 0a453766..cc66e0b3 100644 --- a/apps/server/app.controller.ts +++ b/apps/server/app.controller.ts @@ -20,7 +20,7 @@ export class AppController { hubId: this.hub.hubId, url: this.hub.url, connectionState: this.hub.connectionState, - agentCount: this.hub.listAgents().length, + agentCount: this.hub.listConversations().length, }; } @@ -33,23 +33,23 @@ export class AppController { }; } - @Get("agents") - listAgents() { - return this.hub.listAgents().map((id) => { - const agent = this.hub.getAgent(id); - return { id, closed: agent?.closed ?? true }; + @Get("conversations") + listConversations() { + return this.hub.listConversations().map((id) => { + const conversation = this.hub.getConversation(id); + return { id, closed: conversation?.closed ?? true }; }); } - @Post("agents") - createAgent(@Body() body?: { id?: string }) { - const agent = this.hub.createAgent(body?.id); - return { id: agent.sessionId }; + @Post("conversations") + createConversation(@Body() body?: { id?: string; agentId?: string }) { + const conversation = this.hub.createConversation(body?.id, { agentId: body?.agentId }); + return { id: conversation.sessionId }; } - @Delete("agents/:id") - deleteAgent(@Param("id") id: string) { - const ok = this.hub.closeAgent(id); + @Delete("conversations/:id") + deleteConversation(@Param("id") id: string) { + const ok = this.hub.closeConversation(id); return { ok }; } } diff --git a/apps/server/public/index.html b/apps/server/public/index.html index bcabc2fe..fa974c6e 100644 --- a/apps/server/public/index.html +++ b/apps/server/public/index.html @@ -232,7 +232,7 @@ color: var(--green); } - /* ── Agents section ── */ + /* ── Conversations section ── */ .section-header { display: flex; align-items: center; @@ -347,7 +347,7 @@
-
Agents
+
Conversations
@@ -359,17 +359,17 @@ - +
- Agents -
- Agent ID + Conversation ID Actions
@@ -419,9 +419,9 @@ } async function refresh() { - const [hubRes, agentsRes] = await Promise.all([ + const [hubRes, conversationsRes] = await Promise.all([ fetch(`${API}/hub`).then(r => r.json()), - fetch(`${API}/agents`).then(r => r.json()), + fetch(`${API}/conversations`).then(r => r.json()), ]); // Stats @@ -446,19 +446,19 @@ input.value = hubRes.url || ''; } - // Agent list + // Conversation list const list = document.getElementById('agent-list'); - if (agentsRes.length === 0) { - list.innerHTML = '
No agents yet. Create one to get started.
'; + if (conversationsRes.length === 0) { + list.innerHTML = '
No conversations yet. Create one to get started.
'; } else { - list.innerHTML = agentsRes.map(a => + list.innerHTML = conversationsRes.map(a => `
` + `
` + `${a.id}` + `` + `
` + `
` + - `` + + `` + `
` + `
` ).join(''); @@ -482,13 +482,13 @@ refresh(); } - async function createAgent() { - await fetch(`${API}/agents`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); + async function createConversation() { + await fetch(`${API}/conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); refresh(); } - async function deleteAgent(id) { - await fetch(`${API}/agents/${id}`, { method: 'DELETE' }); + async function deleteConversation(id) { + await fetch(`${API}/conversations/${id}`, { method: 'DELETE' }); refresh(); } diff --git a/apps/web/components/pages/chat-page.tsx b/apps/web/components/pages/chat-page.tsx index db365885..e2b13147 100644 --- a/apps/web/components/pages/chat-page.tsx +++ b/apps/web/components/pages/chat-page.tsx @@ -32,11 +32,12 @@ const ChatPage = () => { /> )} - {pageState === "connected" && client && identity && ( + {pageState === "connected" && client && identity && identity.conversationId && ( )} @@ -49,14 +50,16 @@ function ConnectedChat({ client, hubId, agentId, + conversationId, onDisconnect, }: { client: NonNullable["client"]>; hubId: string; agentId: string; + conversationId: string; onDisconnect: () => void; }) { - const chat = useGatewayChat({ client, hubId, agentId }); + const chat = useGatewayChat({ client, hubId, agentId, conversationId }); return ; } diff --git a/docs/development.md b/docs/development.md index 896f8d83..965a7d33 100644 --- a/docs/development.md +++ b/docs/development.md @@ -52,6 +52,31 @@ pnpm dev:local:archive - `MULTICA_WORKSPACE_DIR`: override workspace root - `MULTICA_RUN_LOG=1`: enable structured run-log output +## Agent / Conversation Semantics + +- `agentId`: logical owner identity (capabilities/profile scope). +- `conversationId`: isolated runtime thread under an agent. +- `sessionId`: internal runner/storage identifier for a conversation. External protocols use `conversationId`. + +Protocol rules: + +- Hub RPC is conversation-first: `createConversation/listConversations/deleteConversation`. +- All message, stream, and verify payloads use `conversationId` (no `sessionId` alias fields). +- New integrations should always pass `conversationId` explicitly. + +Telegram behavior: + +- One Telegram DM binds to one active `conversationId`. +- `/new` creates and switches to a new conversation. +- `/session ` switches the active conversation. +- `/sessions` lists available conversations. + +Channel route behavior: + +- Runtime route key is `channelId:accountId:externalConversationId`. +- Each route key is bound to one Hub `conversationId`. +- Incoming/outgoing channel traffic is isolated per bound conversation (no global first-agent fallback). + ## Local Full-Stack Notes (`pnpm dev:local`) `pnpm dev:local` is the recommended way to run the full local stack for integration work. diff --git a/packages/core/src/agent/run-log.error-detection.test.ts b/packages/core/src/agent/run-log.error-detection.test.ts new file mode 100644 index 00000000..653f1f0d --- /dev/null +++ b/packages/core/src/agent/run-log.error-detection.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { inferRunLogToolIsError } from "./runner.js"; + +describe("inferRunLogToolIsError", () => { + it("returns true when event explicitly marks error", () => { + expect(inferRunLogToolIsError(true, undefined, null)).toBe(true); + }); + + it("returns true when details.error is present", () => { + expect( + inferRunLogToolIsError( + false, + "{\"error\":true}", + { error: "Financial Datasets API error", message: "Invalid ticker" }, + ), + ).toBe(true); + }); + + it("returns true when details.error_type uses boolean-like marker", () => { + expect(inferRunLogToolIsError(false, undefined, { error_type: true })).toBe(true); + expect(inferRunLogToolIsError(false, undefined, { error_type: "true" })).toBe(true); + }); + + it("returns true when text payload starts with error prefix", () => { + expect(inferRunLogToolIsError(false, "error: ENOENT", null)).toBe(true); + }); + + it("returns false for successful tool responses", () => { + expect( + inferRunLogToolIsError( + false, + "{\"domain\":\"finance\",\"action\":\"get_price_snapshot\"}", + { domain: "finance", action: "get_price_snapshot" }, + ), + ).toBe(false); + }); +}); diff --git a/packages/core/src/agent/run-log.ts b/packages/core/src/agent/run-log.ts index 29b52585..3890ea92 100644 --- a/packages/core/src/agent/run-log.ts +++ b/packages/core/src/agent/run-log.ts @@ -71,7 +71,7 @@ import { join } from "path"; import { mkdirSync } from "fs"; import { appendFile } from "fs/promises"; -import { resolveBaseDir, type SessionStorageOptions } from "./session/storage.js"; +import { ensureSessionDir, resolveSessionDir, type SessionStorageOptions } from "./session/storage.js"; export interface RunLog { log(event: string, data?: Record): void; @@ -85,16 +85,10 @@ class FileRunLog implements RunLog { private flushScheduled = false; constructor(sessionId: string, options?: SessionStorageOptions) { - const sessionDir = join(resolveBaseDir(options), sessionId); - try { - mkdirSync(sessionDir, { recursive: true }); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - mkdirSync(sessionDir, { recursive: true }); - } else { - throw err; - } - } + ensureSessionDir(sessionId, options); + const sessionDir = resolveSessionDir(sessionId, options); + // keep an extra guard for run-log-only writes when session dir is cleaned externally + mkdirSync(sessionDir, { recursive: true }); this.filePath = join(sessionDir, "run-log.jsonl"); } diff --git a/packages/core/src/agent/runner.ts b/packages/core/src/agent/runner.ts index 320e76f1..e095a90b 100644 --- a/packages/core/src/agent/runner.ts +++ b/packages/core/src/agent/runner.ts @@ -238,6 +238,38 @@ export function evaluateCustomSkillAuthoringConsent( return { allowAuthoring: false, declined: false }; } +/** + * Infer whether a tool call should be classified as error in run-log. + * + * Some tool adapters encode failures in payload fields (`error`, `error_type`) + * without setting `event.isError=true`. This helper keeps run-log semantics + * consistent for E2E health checks. + */ +export function inferRunLogToolIsError( + eventIsError: unknown, + resultText: string | undefined, + details: Record | null, +): boolean { + if (eventIsError === true) return true; + if (!details) { + return typeof resultText === "string" && /^error[:\s]/i.test(resultText.trim()); + } + + const errorType = details.error_type; + if (typeof errorType === "boolean") return errorType; + if (typeof errorType === "string") { + const normalized = errorType.trim().toLowerCase(); + if (normalized === "true" || normalized === "error") return true; + } + + const errorValue = details.error; + if (typeof errorValue === "string") return errorValue.trim().length > 0; + if (errorValue === true) return true; + if (errorValue && typeof errorValue === "object") return true; + + return typeof resultText === "string" && /^error[:\s]/i.test(resultText.trim()); +} + // ── Run-log result extraction helpers ────────────────────────────────────── // Lightweight extractors for tool_end metadata. These mirror the patterns in // cli/output.ts but are kept separate to avoid CLI-specific dependencies. @@ -400,12 +432,17 @@ export class Agent { // Load session metadata early so stored provider/model can inform defaults this.sessionId = options.sessionId ?? uuidv7(); this.guardedExecApproval = this.createGuardedExecApprovalCallback(options.onExecApprovalNeeded); + const storageAgentId = options.ownerAgentId; this.runLog = createRunLog( options.enableRunLog ?? !!process.env.MULTICA_RUN_LOG, this.sessionId, + storageAgentId ? { agentId: storageAgentId } : undefined, ); const storedMeta = (() => { - const tempSession = new SessionManager({ sessionId: this.sessionId }); + const tempSession = new SessionManager({ + sessionId: this.sessionId, + ...(storageAgentId ? { agentId: storageAgentId } : {}), + }); return tempSession.getMeta(); })(); @@ -554,6 +591,7 @@ export class Agent { // 创建 SessionManager(带 context window 配置) this.session = new SessionManager({ sessionId: this.sessionId, + ...(storageAgentId ? { agentId: storageAgentId } : {}), compactionMode, // Token 模式参数 contextWindowTokens: this.contextWindowGuard.tokens, @@ -1286,8 +1324,7 @@ export class Agent { const resultText = extractRunLogResultText(result); const resultChars = resultText?.length ?? 0; const details = extractRunLogResultDetails(result); - const isError = Boolean((event as any).isError ?? false); - + const isError = inferRunLogToolIsError((event as any).isError, resultText, details); this.currentRunToolExecutions.push({ toolName, isError, diff --git a/packages/core/src/agent/session/session-manager.ts b/packages/core/src/agent/session/session-manager.ts index c51ea1bd..d5b68d05 100644 --- a/packages/core/src/agent/session/session-manager.ts +++ b/packages/core/src/agent/session/session-manager.ts @@ -1,7 +1,13 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import { getModel, type Model, type UserMessage } from "@mariozechner/pi-ai"; import type { SessionEntry, SessionMeta } from "./types.js"; -import { appendEntry, readEntries, resolveSessionPath, writeEntries } from "./storage.js"; +import { + appendEntry, + readEntries, + resolveSessionPath, + writeEntries, + type SessionStorageOptions, +} from "./storage.js"; import { compactMessages, compactMessagesAsync, type CompactionResult } from "./compaction.js"; import { estimateTokenUsage, estimateMessagesTokens, shouldCompact as shouldCompactTokens } from "../context-window/index.js"; import { credentialManager } from "../credentials.js"; @@ -36,6 +42,8 @@ function getSummaryApiKey(): string | undefined { export type SessionManagerOptions = { sessionId: string; baseDir?: string | undefined; + /** Logical owner agent ID for hierarchical session storage. */ + agentId?: string | undefined; // Compaction mode configuration /** Compaction mode: "tokens" uses token awareness, "summary" uses LLM summary (default) */ @@ -81,6 +89,7 @@ export type SessionManagerOptions = { export class SessionManager { private readonly sessionId: string; private readonly baseDir: string | undefined; + private readonly agentId: string | undefined; private readonly compactionMode: "tokens" | "summary"; // Token mode private readonly contextWindowTokens: number; @@ -108,6 +117,7 @@ export class SessionManager { constructor(options: SessionManagerOptions) { this.sessionId = options.sessionId; this.baseDir = options.baseDir; + this.agentId = options.agentId; // Compaction mode (default: summary with LLM-based summarization) this.compactionMode = options.compactionMode ?? "summary"; @@ -174,11 +184,11 @@ export class SessionManager { } loadEntries(): SessionEntry[] { - return readEntries(this.sessionId, { baseDir: this.baseDir }); + return readEntries(this.sessionId, this.getStorageOptions()); } async repairIfNeeded(warn?: (message: string) => void): Promise { - const filePath = resolveSessionPath(this.sessionId, { baseDir: this.baseDir }); + const filePath = resolveSessionPath(this.sessionId, this.getStorageOptions()); return repairSessionFileIfNeeded({ sessionFile: filePath, ...(warn !== undefined ? { warn } : {}) }); } @@ -240,7 +250,7 @@ export class SessionManager { appendEntry( this.sessionId, { type: "meta", meta, timestamp: Date.now() }, - { baseDir: this.baseDir }, + this.getStorageOptions(), ), ); } @@ -258,7 +268,7 @@ export class SessionManager { contextWindowTokens: this.contextWindowTokens, settings: this.toolResultTruncation, saveArtifact: (toolCallId, content) => - saveToolResultArtifact(this.sessionId, toolCallId, content, { baseDir: this.baseDir }), + saveToolResultArtifact(this.sessionId, toolCallId, content, this.getStorageOptions()), }); if (result.truncated) { persistMessage = result.message; @@ -286,7 +296,7 @@ export class SessionManager { : {}), ...(options?.source !== undefined ? { source: options.source } : {}), }, - { baseDir: this.baseDir }, + this.getStorageOptions(), ), ); } @@ -463,7 +473,7 @@ export class SessionManager { }); await this.enqueue(() => - writeEntries(this.sessionId, entries, { baseDir: this.baseDir }), + writeEntries(this.sessionId, entries, this.getStorageOptions()), ); return result; } @@ -483,4 +493,11 @@ export class SessionManager { }); return this.queue; } + + private getStorageOptions(): SessionStorageOptions { + return { + ...(this.baseDir !== undefined ? { baseDir: this.baseDir } : {}), + ...(this.agentId !== undefined ? { agentId: this.agentId } : {}), + }; + } } diff --git a/packages/core/src/agent/session/storage.test.ts b/packages/core/src/agent/session/storage.test.ts index 37c88bef..a97b4c7e 100644 --- a/packages/core/src/agent/session/storage.test.ts +++ b/packages/core/src/agent/session/storage.test.ts @@ -58,6 +58,11 @@ describe("session/storage", () => { const result = resolveSessionDir("session-123-abc", { baseDir: testBaseDir }); expect(result).toBe(join(testBaseDir, "session-123-abc")); }); + + it("should return hierarchical path when agentId is provided", () => { + const result = resolveSessionDir("conv-1", { baseDir: testBaseDir, agentId: "agent-1" }); + expect(result).toBe(join(testBaseDir, "agent-1", "conv-1")); + }); }); describe("resolveSessionPath", () => { @@ -65,6 +70,11 @@ describe("session/storage", () => { const result = resolveSessionPath("test-session", { baseDir: testBaseDir }); expect(result).toBe(join(testBaseDir, "test-session", "session.jsonl")); }); + + it("should return hierarchical path when agentId is provided", () => { + const result = resolveSessionPath("conv-1", { baseDir: testBaseDir, agentId: "agent-1" }); + expect(result).toBe(join(testBaseDir, "agent-1", "conv-1", "session.jsonl")); + }); }); describe("ensureSessionDir", () => { @@ -84,6 +94,20 @@ describe("session/storage", () => { expect(() => ensureSessionDir(sessionId, { baseDir: testBaseDir })).not.toThrow(); expect(existsSync(dir)).toBe(true); }); + + it("should migrate legacy flat directory into hierarchical path", () => { + const sessionId = "legacy-migrate"; + const legacyDir = join(testBaseDir, sessionId); + mkdirSync(legacyDir, { recursive: true }); + writeFileSync(join(legacyDir, "session.jsonl"), '{"type":"meta","meta":{},"timestamp":1}\n'); + + ensureSessionDir(sessionId, { baseDir: testBaseDir, agentId: "agent-1" }); + + const nextDir = join(testBaseDir, "agent-1", sessionId); + expect(existsSync(nextDir)).toBe(true); + expect(existsSync(join(nextDir, "session.jsonl"))).toBe(true); + expect(existsSync(legacyDir)).toBe(false); + }); }); describe("readEntries", () => { @@ -102,6 +126,21 @@ describe("session/storage", () => { expect(entries).toEqual([]); }); + it("should read legacy flat session when hierarchical path is requested", () => { + const sessionId = "legacy-read"; + const legacyDir = join(testBaseDir, sessionId); + mkdirSync(legacyDir, { recursive: true }); + const entry: SessionEntry = { + type: "message", + message: { role: "user", content: "legacy" } as any, + timestamp: 1000, + }; + writeFileSync(join(legacyDir, "session.jsonl"), `${JSON.stringify(entry)}\n`); + + const entries = readEntries(sessionId, { baseDir: testBaseDir, agentId: "agent-1" }); + expect(entries).toEqual([entry]); + }); + it("should parse valid JSONL entries", () => { const sessionId = "valid-session"; const dir = join(testBaseDir, sessionId); diff --git a/packages/core/src/agent/session/storage.ts b/packages/core/src/agent/session/storage.ts index 51c8245c..823dd01c 100644 --- a/packages/core/src/agent/session/storage.ts +++ b/packages/core/src/agent/session/storage.ts @@ -1,5 +1,5 @@ import { join } from "path"; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs"; import { appendFile, writeFile } from "fs/promises"; import { createHash } from "node:crypto"; import type { SessionEntry } from "./types.js"; @@ -8,6 +8,8 @@ import { acquireSessionWriteLock } from "./session-write-lock.js"; export type SessionStorageOptions = { baseDir?: string | undefined; + /** Owner agent ID. When provided, sessions are stored under agent/conversation hierarchy. */ + agentId?: string | undefined; }; /** Minimum base64 data length to externalize (32KB decoded ≈ 43KB base64) */ @@ -17,10 +19,35 @@ export function resolveBaseDir(options?: SessionStorageOptions) { return options?.baseDir ?? join(DATA_DIR, "sessions"); } -export function resolveSessionDir(sessionId: string, options?: SessionStorageOptions) { +function normalizeId(value: string | undefined): string | undefined { + const normalized = (value ?? "").trim(); + return normalized.length > 0 ? normalized : undefined; +} + +function resolveLegacySessionDir(sessionId: string, options?: SessionStorageOptions): string { return join(resolveBaseDir(options), sessionId); } +function resolvePreferredSessionDir(sessionId: string, options?: SessionStorageOptions): string { + const normalizedAgentId = normalizeId(options?.agentId); + if (!normalizedAgentId) { + return resolveLegacySessionDir(sessionId, options); + } + return join(resolveBaseDir(options), normalizedAgentId, sessionId); +} + +function resolveExistingSessionDir(sessionId: string, options?: SessionStorageOptions): string { + const preferred = resolvePreferredSessionDir(sessionId, options); + if (existsSync(preferred)) return preferred; + const legacy = resolveLegacySessionDir(sessionId, options); + if (legacy !== preferred && existsSync(legacy)) return legacy; + return preferred; +} + +export function resolveSessionDir(sessionId: string, options?: SessionStorageOptions) { + return resolvePreferredSessionDir(sessionId, options); +} + export function resolveSessionPath(sessionId: string, options?: SessionStorageOptions) { return join(resolveSessionDir(sessionId, options), "session.jsonl"); } @@ -30,6 +57,19 @@ export function resolveMediaDir(sessionId: string, options?: SessionStorageOptio } export function ensureSessionDir(sessionId: string, options?: SessionStorageOptions) { + const preferredDir = resolvePreferredSessionDir(sessionId, options); + const legacyDir = resolveLegacySessionDir(sessionId, options); + + if (preferredDir !== legacyDir && existsSync(legacyDir) && !existsSync(preferredDir)) { + try { + mkdirSync(join(preferredDir, ".."), { recursive: true }); + renameSync(legacyDir, preferredDir); + return; + } catch { + // Fall through to normal mkdir flow below. + } + } + const dir = resolveSessionDir(sessionId, options); // mkdirSync with recursive is idempotent (no-op if dir exists), // so skip the existsSync check to avoid a TOCTOU race. @@ -127,7 +167,7 @@ function internalizeBlock( // Format A ref: { type: "image", $ref: "media/.bin" } if (typeof block.$ref === "string") { - const filePath = join(resolveSessionDir(sessionId, options), block.$ref); + const filePath = join(resolveExistingSessionDir(sessionId, options), block.$ref); try { const buffer = readFileSync(filePath); const data = buffer.toString("base64"); @@ -140,7 +180,7 @@ function internalizeBlock( // Format B ref: { type: "image", source: { type: "$ref", path: "media/.bin" } } if (block.source && typeof block.source === "object" && block.source.type === "$ref") { - const filePath = join(resolveSessionDir(sessionId, options), block.source.path); + const filePath = join(resolveExistingSessionDir(sessionId, options), block.source.path); try { const buffer = readFileSync(filePath); const data = buffer.toString("base64"); @@ -240,7 +280,7 @@ function internalizeImages( // ─── Public API ───────────────────────────────────────────────────────────── export function readEntries(sessionId: string, options?: SessionStorageOptions): SessionEntry[] { - const filePath = resolveSessionPath(sessionId, options); + const filePath = join(resolveExistingSessionDir(sessionId, options), "session.jsonl"); if (!existsSync(filePath)) return []; const content = readFileSync(filePath, "utf8"); const lines = content.split("\n").filter(Boolean); diff --git a/packages/core/src/agent/tools/exec-approval-types.ts b/packages/core/src/agent/tools/exec-approval-types.ts index 9b1ab449..671883b2 100644 --- a/packages/core/src/agent/tools/exec-approval-types.ts +++ b/packages/core/src/agent/tools/exec-approval-types.ts @@ -24,6 +24,8 @@ export interface ExecApprovalRequest { approvalId: string; /** Agent that initiated the command */ agentId: string; + /** Conversation ID that initiated the command. */ + conversationId: string; /** Shell command to execute */ command: string; /** Working directory */ diff --git a/packages/core/src/agent/types.ts b/packages/core/src/agent/types.ts index 7663bba6..bc895d1a 100644 --- a/packages/core/src/agent/types.ts +++ b/packages/core/src/agent/types.ts @@ -41,6 +41,8 @@ export type AgentOptions = { /** Command execution directory */ cwd?: string | undefined; sessionId?: string | undefined; + /** Logical owner agent ID for hierarchical session storage (agent/conversation). */ + ownerAgentId?: string | undefined; logger?: AgentLogger | undefined; // === Context Window Guard Configuration === diff --git a/packages/core/src/channels/manager.test.ts b/packages/core/src/channels/manager.test.ts index 463eb903..e8e31cc8 100644 --- a/packages/core/src/channels/manager.test.ts +++ b/packages/core/src/channels/manager.test.ts @@ -1,31 +1,69 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; import type { Hub } from "../hub/hub.js"; import type { AsyncAgent } from "../agent/async-agent.js"; -import type { ChannelPlugin } from "./types.js"; +import type { ChannelPlugin, ChannelMessage } from "./types.js"; import { ChannelManager } from "./manager.js"; type AgentEventCallback = (event: unknown) => void; -function createHarness() { +type AgentHarness = { + agent: AsyncAgent; + write: ReturnType; + emit: (event: unknown) => void; +}; + +function createAgentHarness(sessionId: string): AgentHarness { let subscriber: AgentEventCallback | null = null; + const write = vi.fn(); const agent = { - sessionId: "agent-1", + sessionId, + closed: false, subscribe: (callback: AgentEventCallback) => { subscriber = callback; return () => { subscriber = null; }; }, + write, } as unknown as AsyncAgent; + return { + agent, + write, + emit: (event: unknown) => { + subscriber?.(event); + }, + }; +} + +function createHarness() { + const conversations = new Map(); + let conversationCounter = 0; + + const createConversation = vi.fn(() => { + conversationCounter += 1; + const id = `conv-${conversationCounter}`; + const harness = createAgentHarness(id); + conversations.set(id, harness); + return harness.agent; + }); + const hub = { - listAgents: () => ["agent-1"], - getAgent: () => agent, + listConversations: vi.fn(() => ["existing-conv"]), + createConversation, + getConversation: vi.fn((conversationId: string) => conversations.get(conversationId)?.agent), + getConversationAgentId: vi.fn(() => "agent-1"), + broadcastInbound: vi.fn(), } as unknown as Hub; const replyText = vi.fn(async () => {}); const sendText = vi.fn(async () => {}); + const addReaction = vi.fn(async () => {}); + const plugin: ChannelPlugin = { id: "telegram", meta: { @@ -43,39 +81,83 @@ function createHarness() { outbound: { replyText, sendText, + addReaction, }, }; - const manager = new ChannelManager(hub); - (manager as unknown as { lastRoute: unknown }).lastRoute = { + const routeIncomingToManager = (target: ChannelManager, message: ChannelMessage) => { + (target as unknown as { + routeIncoming: (plugin: ChannelPlugin, accountId: string, message: ChannelMessage) => void; + }).routeIncoming(plugin, "default", message); + }; + + const getConversationIdByExternal = ( + target: ChannelManager, + externalConversationId: string, + ): string | undefined => { + const bindings = (target as unknown as { + routeBindings: Map; + }).routeBindings; + + for (const [routeKey, binding] of bindings.entries()) { + if (routeKey.endsWith(`:${externalConversationId}`)) { + return binding.hubConversationId; + } + } + return undefined; + }; + + return { + hub, + replyText, + sendText, + addReaction, plugin, - deliveryCtx: { - channel: "telegram", - accountId: "default", - conversationId: "chat-1", - replyToMessageId: "in-1", - }, + createManager: (routeBindingsPath: string) => new ChannelManager(hub, { routeBindingsPath }), + routeIncomingToManager, + getConversationIdByExternal, + conversations, }; - (manager as unknown as { ensureSubscribed: () => void }).ensureSubscribed(); - - const emit = (event: unknown) => subscriber?.(event); - - return { manager, replyText, sendText, emit }; } -describe("channel manager heartbeat filtering", () => { +describe("channel manager route isolation", () => { + let testDir: string; + + beforeEach(() => { + vi.useFakeTimers(); + testDir = mkdtempSync(join(tmpdir(), "channel-manager-")); + }); + afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); + rmSync(testDir, { recursive: true, force: true }); }); it("suppresses pure HEARTBEAT_OK in channel outbound", async () => { - const { manager, replyText, sendText, emit } = createHarness(); + const routeBindingsPath = join(testDir, "route-bindings.json"); + const { createManager, routeIncomingToManager, getConversationIdByExternal, conversations, replyText, sendText } = createHarness(); + const manager = createManager(routeBindingsPath); - emit({ + routeIncomingToManager(manager, { + messageId: "in-1", + conversationId: "chat-1", + senderId: "user-1", + text: "hi", + chatType: "direct", + }); + + const hubConversationId = getConversationIdByExternal(manager, "chat-1"); + expect(hubConversationId).toBeDefined(); + + const harness = conversations.get(hubConversationId!); + expect(harness).toBeDefined(); + + harness!.emit({ type: "message_start", message: { role: "assistant", content: [] }, }); - emit({ + harness!.emit({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "HEARTBEAT_OK" }] }, }); @@ -89,13 +171,29 @@ describe("channel manager heartbeat filtering", () => { }); it("keeps forwarding normal assistant replies", async () => { - const { manager, replyText, sendText, emit } = createHarness(); + const routeBindingsPath = join(testDir, "route-bindings.json"); + const { createManager, routeIncomingToManager, getConversationIdByExternal, conversations, replyText, sendText } = createHarness(); + const manager = createManager(routeBindingsPath); - emit({ + routeIncomingToManager(manager, { + messageId: "in-1", + conversationId: "chat-1", + senderId: "user-1", + text: "hi", + chatType: "direct", + }); + + const hubConversationId = getConversationIdByExternal(manager, "chat-1"); + expect(hubConversationId).toBeDefined(); + + const harness = conversations.get(hubConversationId!); + expect(harness).toBeDefined(); + + harness!.emit({ type: "message_start", message: { role: "assistant", content: [] }, }); - emit({ + harness!.emit({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "Reminder: check inbox." }] }, }); @@ -116,4 +214,113 @@ describe("channel manager heartbeat filtering", () => { manager.stopAll(); }); + + it("binds different external conversations to isolated hub conversations", async () => { + const { + createManager, + hub, + routeIncomingToManager, + getConversationIdByExternal, + conversations, + } = createHarness(); + const manager = createManager(join(testDir, "route-bindings.json")); + + routeIncomingToManager(manager, { + messageId: "in-a1", + conversationId: "chat-a", + senderId: "user-a", + text: "alpha", + chatType: "group", + }); + + routeIncomingToManager(manager, { + messageId: "in-b1", + conversationId: "chat-b", + senderId: "user-b", + text: "beta", + chatType: "group", + }); + + await vi.advanceTimersByTimeAsync(600); + + const convA = getConversationIdByExternal(manager, "chat-a"); + const convB = getConversationIdByExternal(manager, "chat-b"); + + expect(convA).toBeDefined(); + expect(convB).toBeDefined(); + expect(convA).not.toBe(convB); + + const harnessA = conversations.get(convA!); + const harnessB = conversations.get(convB!); + + expect(harnessA?.write).toHaveBeenCalledTimes(1); + expect(harnessA?.write.mock.calls[0]?.[0]).toContain("alpha"); + + expect(harnessB?.write).toHaveBeenCalledTimes(1); + expect(harnessB?.write.mock.calls[0]?.[0]).toContain("beta"); + + // Same external route should reuse existing hub conversation binding. + routeIncomingToManager(manager, { + messageId: "in-a2", + conversationId: "chat-a", + senderId: "user-a", + text: "alpha-2", + chatType: "group", + }); + + await vi.advanceTimersByTimeAsync(600); + + expect(getConversationIdByExternal(manager, "chat-a")).toBe(convA); + expect((hub as unknown as { createConversation: ReturnType }).createConversation).toHaveBeenCalledTimes(2); + expect(harnessA?.write).toHaveBeenCalledTimes(2); + expect(harnessA?.write.mock.calls[1]?.[0]).toContain("alpha-2"); + + manager.stopAll(); + }); + + it("restores route bindings from disk after manager restart", async () => { + const routeBindingsPath = join(testDir, "route-bindings.json"); + const { + hub, + createManager, + routeIncomingToManager, + getConversationIdByExternal, + conversations, + } = createHarness(); + + const managerA = createManager(routeBindingsPath); + routeIncomingToManager(managerA, { + messageId: "in-p1", + conversationId: "chat-persist", + senderId: "user-p", + text: "persist-1", + chatType: "direct", + }); + await vi.advanceTimersByTimeAsync(600); + + const firstConversationId = getConversationIdByExternal(managerA, "chat-persist"); + expect(firstConversationId).toBeDefined(); + const harness = conversations.get(firstConversationId!); + expect(harness?.write).toHaveBeenCalledTimes(1); + + managerA.stopAll(); + + const managerB = createManager(routeBindingsPath); + routeIncomingToManager(managerB, { + messageId: "in-p2", + conversationId: "chat-persist", + senderId: "user-p", + text: "persist-2", + chatType: "direct", + }); + await vi.advanceTimersByTimeAsync(600); + + const restoredConversationId = getConversationIdByExternal(managerB, "chat-persist"); + expect(restoredConversationId).toBe(firstConversationId); + expect((hub as unknown as { createConversation: ReturnType }).createConversation).toHaveBeenCalledTimes(1); + expect(harness?.write).toHaveBeenCalledTimes(2); + expect(harness?.write.mock.calls[1]?.[0]).toContain("persist-2"); + + managerB.stopAll(); + }); }); diff --git a/packages/core/src/channels/manager.ts b/packages/core/src/channels/manager.ts index cbe7800d..d6bae2aa 100644 --- a/packages/core/src/channels/manager.ts +++ b/packages/core/src/channels/manager.ts @@ -1,15 +1,17 @@ /** - * Channel Manager — bridges messaging channels to the Hub's agent. + * Channel Manager — bridges messaging channels to Hub conversations. * - * Design: One Hub, one Agent. Channels are just alternative input/output surfaces. - * - Incoming: channel message → agent.write(text) (same as desktop/gateway) - * - Outgoing: agent reply → check lastRoute → forward to originating channel + * Design: + * - Incoming channel messages are keyed by routeKey + * (channelId + accountId + externalConversationId). + * - Each routeKey is bound to one Hub conversationId. + * - Outgoing assistant events are delivered back through the bound route. * - * Uses "last route" pattern: whoever sent the last message gets the reply. + * This keeps channel routes isolated across conversations and avoids + * the old "first active agent" coupling. * * @see docs/channels/README.md — Channel system overview * @see docs/channels/media-handling.md — Media processing pipeline - * @see docs/message-paths.md — All three message paths (Desktop / Web / Channel) */ import type { Hub } from "../hub/hub.js"; @@ -31,6 +33,27 @@ import { describeImage } from "../media/describe-image.js"; import { describeVideo } from "../media/describe-video.js"; import { InboundDebouncer } from "./inbound-debouncer.js"; import { extname } from "node:path"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { DATA_DIR } from "@multica/utils"; + +const ROUTE_BINDING_STORE_VERSION = 1; + +interface RouteBindingStoreEntry { + routeKey: string; + hubConversationId: string; + hubAgentId: string; + updatedAt: number; +} + +interface RouteBindingStoreFile { + version: number; + bindings: RouteBindingStoreEntry[]; +} + +interface ChannelManagerOptions { + routeBindingsPath?: string; +} interface AccountHandle { channelId: string; @@ -39,55 +62,77 @@ interface AccountHandle { state: ChannelAccountState; } -/** Tracks where the last message came from, so replies go back there. */ interface LastRoute { + routeKey: string; plugin: ChannelPlugin; deliveryCtx: DeliveryContext; - /** Chat type of the originating message (for source prefix) */ + hubConversationId: string; + hubAgentId: string; chatType?: "direct" | "group" | undefined; } +interface RouteBinding { + routeKey: string; + hubConversationId: string; + hubAgentId: string; + updatedAt: number; + lastRoute: LastRoute | null; +} + +interface PendingRoute { + route: LastRoute; + acks: LastRoute[]; +} + +interface ConversationState { + pendingRoutes: PendingRoute[]; + activeRoute: LastRoute | null; + activeAcks: LastRoute[]; + ackBuffer: LastRoute[]; + aggregator: MessageAggregator | null; + typingTimer: ReturnType | null; + statusMessageId: string | null; +} + +interface ResolveRouteResult { + binding: RouteBinding; + conversation: AsyncAgent; +} + export class ChannelManager { private readonly hub: Hub; + private readonly routeBindingsPath: string; + /** Running accounts keyed by "channelId:accountId" */ private readonly accounts = new Map(); - /** Where the last channel message came from (used for typing/reactions/errors) */ + + /** routeKey -> route binding */ + private readonly routeBindings = new Map(); + + /** hubConversationId -> runtime state */ + private readonly conversationStates = new Map(); + + /** hubConversationId -> unsubscribe callback */ + private readonly conversationSubscriptions = new Map void>(); + + /** Latest route seen globally (best-effort fallback for send_file) */ private lastRoute: LastRoute | null = null; - /** - * FIFO queue of route snapshots + their ack targets, captured at each debouncer flush. - * Each agent.write() gets its own entry; dequeued on agent_start. - */ - private pendingRoutes: { route: LastRoute; acks: LastRoute[] }[] = []; - /** Route for the currently active agent run (set on agent_start, cleared on agent_end). */ - private activeRoute: LastRoute | null = null; - /** All messages in the current run's batch that have 👀 (cleared on agent_end). */ - private activeAcks: LastRoute[] = []; - /** Accumulates message routes for 👀 removal between debouncer flushes. */ - private ackBuffer: LastRoute[] = []; - /** Unsubscribe function for the agent subscriber */ - private agentUnsubscribe: (() => void) | null = null; - /** Session ID of the currently subscribed agent (for stale detection) */ - private subscribedAgentId: string | null = null; - /** Current aggregator for buffering streaming responses */ - private aggregator: MessageAggregator | null = null; - /** Typing indicator interval (repeats every 5s to keep Telegram typing visible) */ - private typingTimer: ReturnType | null = null; - /** Platform message ID of the editable status message (for tool narration updates) */ - private statusMessageId: string | null = null; - /** - * Inbound message debouncer — batches rapid-fire messages from the same - * conversation into a single agent.write() call. - * Initialized lazily on first message; uses the current agent reference. - */ + + /** Inbound debouncer keyed by routeKey */ private debouncer: InboundDebouncer | null = null; - constructor(hub: Hub) { + constructor(hub: Hub, options?: ChannelManagerOptions) { this.hub = hub; + this.routeBindingsPath = options?.routeBindingsPath ?? join(DATA_DIR, "channels", "route-bindings.json"); + this.loadRouteBindings(); } /** Start all configured channel accounts */ async startAll(): Promise { console.log("[Channels] Starting all channels..."); + if (this.routeBindings.size === 0) { + this.loadRouteBindings(); + } const config = loadChannelsConfig(); const plugins = listChannels(); @@ -112,9 +157,6 @@ export class ChannelManager { await this.startAccount(plugin.id, accountId, account); } } - - // Try to subscribe eagerly; if no agent yet, routeIncoming will retry lazily - this.ensureSubscribed(); } /** @@ -173,165 +215,337 @@ export class ChannelManager { } } - /** Get the Hub's current agent (the first active one) */ - private getHubAgent(): AsyncAgent | undefined { - const agentIds = this.hub.listAgents(); - if (agentIds.length === 0) { - console.warn("[Channels] No agent available in Hub"); - return undefined; - } - const agent = this.hub.getAgent(agentIds[0]!); - return agent; + private makeRouteKey(channelId: string, accountId: string, externalConversationId: string): string { + return `${channelId}:${accountId}:${externalConversationId}`; } - /** - * Ensure we're subscribed to the current Hub agent for outbound routing. - * Lazily called from routeIncoming — handles agent not yet available at - * startup and re-subscribes if the agent has changed. - */ - private ensureSubscribed(): void { - const agent = this.getHubAgent(); - if (!agent) return; + private cloneRoute(route: LastRoute): LastRoute { + return { + ...route, + deliveryCtx: { ...route.deliveryCtx }, + }; + } - // Already subscribed to the current agent - if (this.subscribedAgentId === agent.sessionId) return; + private createRoute( + routeKey: string, + plugin: ChannelPlugin, + accountId: string, + externalConversationId: string, + messageId: string, + chatType: "direct" | "group", + hubConversationId: string, + hubAgentId: string, + ): LastRoute { + return { + routeKey, + plugin, + deliveryCtx: { + channel: plugin.id, + accountId, + conversationId: externalConversationId, + replyToMessageId: messageId, + }, + hubConversationId, + hubAgentId, + chatType, + }; + } - // Unsubscribe from stale agent - if (this.agentUnsubscribe) { - console.log(`[Channels] Agent changed, re-subscribing (${this.subscribedAgentId} → ${agent.sessionId})`); - this.agentUnsubscribe(); + private getConversationState(conversationId: string): ConversationState { + const existing = this.conversationStates.get(conversationId); + if (existing) return existing; + + const state: ConversationState = { + pendingRoutes: [], + activeRoute: null, + activeAcks: [], + ackBuffer: [], + aggregator: null, + typingTimer: null, + statusMessageId: null, + }; + this.conversationStates.set(conversationId, state); + return state; + } + + private stopTypingForConversation(conversationId: string): void { + const state = this.conversationStates.get(conversationId); + if (!state?.typingTimer) return; + clearInterval(state.typingTimer); + state.typingTimer = null; + } + + private startTypingForRoute(route: LastRoute): void { + const state = this.getConversationState(route.hubConversationId); + this.stopTypingForConversation(route.hubConversationId); + if (!route.plugin.outbound.sendTyping) return; + + const send = () => route.plugin.outbound.sendTyping!(route.deliveryCtx).catch(() => {}); + void send(); + state.typingTimer = setInterval(send, 5000); + } + + private cleanupConversationState(conversationId: string, options?: { unsubscribe?: boolean }): void { + this.stopTypingForConversation(conversationId); + + const state = this.conversationStates.get(conversationId); + if (state) { + state.pendingRoutes = []; + state.activeRoute = null; + state.activeAcks = []; + state.ackBuffer = []; + state.aggregator = null; + state.statusMessageId = null; + this.conversationStates.delete(conversationId); } - console.log(`[Channels] Subscribing to agent ${agent.sessionId} for outbound routing`); - this.subscribedAgentId = agent.sessionId; + if (options?.unsubscribe) { + const unsubscribe = this.conversationSubscriptions.get(conversationId); + if (unsubscribe) { + unsubscribe(); + this.conversationSubscriptions.delete(conversationId); + } + } + } - this.agentUnsubscribe = agent.subscribe((event) => { - const maybeMessage = (event as { message?: { role?: string } }).message; - const role = maybeMessage?.role; + private resolveDefaultAgentAndConversation(): { agentId: string; conversation?: AsyncAgent } { + const existingConversationId = this.hub.listConversations()[0]; + if (existingConversationId) { + const existingAgentId = this.hub.getConversationAgentId(existingConversationId) ?? existingConversationId; + return { agentId: existingAgentId }; + } - // Activate the next pending route + acks when a new agent run starts. - if (event.type === "agent_start") { - const entry = this.pendingRoutes.shift(); - if (entry) { - this.activeRoute = entry.route; - this.activeAcks = entry.acks; - console.log(`[Channels] agent_start: activeRoute replyTo=${entry.route.deliveryCtx.replyToMessageId}, acks=${entry.acks.length}`); - } + const mainConversation = this.hub.createConversation(); + const agentId = this.hub.getConversationAgentId(mainConversation.sessionId) ?? mainConversation.sessionId; + return { agentId, conversation: mainConversation }; + } + + private resolveOrCreateRouteBinding( + plugin: ChannelPlugin, + accountId: string, + externalConversationId: string, + messageId: string, + chatType: "direct" | "group", + ): ResolveRouteResult | null { + const routeKey = this.makeRouteKey(plugin.id, accountId, externalConversationId); + const existing = this.routeBindings.get(routeKey); + + if (existing) { + const existingConversation = this.hub.getConversation(existing.hubConversationId); + if (existingConversation && !existingConversation.closed) { + existing.lastRoute = this.createRoute( + routeKey, + plugin, + accountId, + externalConversationId, + messageId, + chatType, + existing.hubConversationId, + existing.hubAgentId, + ); + existing.updatedAt = Date.now(); + this.routeBindings.set(routeKey, existing); + return { binding: existing, conversation: existingConversation }; } - // Agent run complete — remove 👀 from all batch messages, conditionally stop typing. - if (event.type === "agent_end") { - for (const ack of this.activeAcks) { - if (ack.plugin.outbound.removeReaction) { - console.log(`[Channels] agent_end: removing 👀 from replyTo=${ack.deliveryCtx.replyToMessageId}`); - void ack.plugin.outbound.removeReaction(ack.deliveryCtx).catch(() => {}); - } - } - this.activeRoute = null; - this.activeAcks = []; - this.statusMessageId = null; - if (this.pendingRoutes.length === 0) { - console.log("[Channels] agent_end: no more pending, stopping typing"); - this.stopTyping(); - } else { - console.log(`[Channels] agent_end: ${this.pendingRoutes.length} pending run(s), keeping typing`); - } - } + // Conversation runtime disappeared — re-create a conversation under the same agent when possible. + this.cleanupConversationState(existing.hubConversationId, { unsubscribe: true }); + const recreated = this.hub.createConversation(undefined, { agentId: existing.hubAgentId }); + const recreatedConversationId = recreated.sessionId; + const recreatedAgentId = this.hub.getConversationAgentId(recreatedConversationId) ?? existing.hubAgentId; - // No active channel route — skip (reply goes to desktop/gateway only) - if (!this.lastRoute) return; + existing.hubConversationId = recreatedConversationId; + existing.hubAgentId = recreatedAgentId; + existing.updatedAt = Date.now(); + existing.lastRoute = this.createRoute( + routeKey, + plugin, + accountId, + externalConversationId, + messageId, + chatType, + recreatedConversationId, + recreatedAgentId, + ); + this.routeBindings.set(routeKey, existing); + this.persistRouteBindings(); + return { binding: existing, conversation: recreated }; + } - // Handle agent errors — notify the channel user - if (event.type === "agent_error") { - this.stopTyping(); - for (const ack of this.activeAcks) { - if (ack.plugin.outbound.removeReaction) { - void ack.plugin.outbound.removeReaction(ack.deliveryCtx).catch(() => {}); - } - } - this.activeRoute = null; - this.activeAcks = []; - this.statusMessageId = null; - const errorMsg = (event as { message?: string }).message ?? "Unknown error"; - console.error(`[Channels] Agent error: ${errorMsg}`); - const route = this.lastRoute; - if (route) { - void route.plugin.outbound.sendText(route.deliveryCtx, `[Error] ${errorMsg}`).catch((err) => { - console.error(`[Channels] Failed to send error to channel: ${err}`); - }); - } - return; - } + const { agentId, conversation: maybeMainConversation } = this.resolveDefaultAgentAndConversation(); + const conversation = maybeMainConversation ?? this.hub.createConversation(undefined, { agentId }); + const hubConversationId = conversation.sessionId; + const hubAgentId = this.hub.getConversationAgentId(hubConversationId) ?? agentId; - // Only forward assistant message events - if (event.type === "message_start" || event.type === "message_update" || event.type === "message_end") { - if (role !== "assistant") return; - } else { - // Non-message events (tool_execution etc.) — skip for channels - return; - } + const binding: RouteBinding = { + routeKey, + hubConversationId, + hubAgentId, + updatedAt: Date.now(), + lastRoute: this.createRoute( + routeKey, + plugin, + accountId, + externalConversationId, + messageId, + chatType, + hubConversationId, + hubAgentId, + ), + }; + this.routeBindings.set(routeKey, binding); + this.persistRouteBindings(); - // Keep heartbeat acknowledgements internal (same behavior as desktop/gateway stream path). - if (isHeartbeatAckEvent(event)) { - if (event.type === "message_end") { - this.aggregator = null; - } - return; - } + console.log( + `[Channels] route bind: ${routeKey} -> conversation=${hubConversationId} (agent=${hubAgentId})`, + ); - // Ensure aggregator exists for this response - if (event.type === "message_start") { - this.createAggregator(); - } + return { binding, conversation }; + } - // Tool narration: if the assistant message contains tool_use blocks, - // it's intermediate narration (e.g. "Let me search...") before a tool call. - // Send/edit an editable status message instead of the normal reply flow. - if (event.type === "message_end" && role === "assistant") { - const message = (event as { message?: Parameters[0] }).message; - if (hasToolUse(message)) { - this.aggregator?.reset(); - this.aggregator = null; + private ensureConversationSubscribed(conversation: AsyncAgent): void { + const conversationId = conversation.sessionId; + if (this.conversationSubscriptions.has(conversationId)) return; - const route = this.activeRoute ?? this.lastRoute; - const narration = extractText(message); - if (route && narration) { - const { plugin, deliveryCtx } = route; - void this.sendOrEditStatus(plugin, deliveryCtx, narration); - } - return; - } - } - - if (this.aggregator) { - this.aggregator.handleEvent(event); - } - - // Finalize aggregator per assistant message (may fire multiple times in multi-turn runs). - // Typing and ack removal are handled at agent_end, not here. - if (event.type === "message_end" && role === "assistant") { - this.aggregator = null; - } + console.log(`[Channels] Subscribing to conversation ${conversationId} for outbound routing`); + const unsubscribe = conversation.subscribe((event) => { + this.handleConversationEvent(conversationId, event); }); + this.conversationSubscriptions.set(conversationId, unsubscribe); } - /** - * Create a fresh aggregator wired to the activeRoute (snapshotted at flush time). - * Falls back to lastRoute for non-debounced paths (e.g. direct writes). - */ - private createAggregator(): void { - const route = this.activeRoute ?? this.lastRoute; + private findRouteForConversation(conversationId: string): LastRoute | null { + for (const binding of this.routeBindings.values()) { + if (binding.hubConversationId === conversationId && binding.lastRoute) { + return this.cloneRoute(binding.lastRoute); + } + } + return null; + } + + private handleConversationEvent(conversationId: string, event: unknown): void { + const state = this.getConversationState(conversationId); + const maybeMessage = (event as { message?: { role?: string } }).message; + const role = maybeMessage?.role; + + // Activate the next pending route + acks when a new agent run starts. + if ((event as { type?: string }).type === "agent_start") { + const entry = state.pendingRoutes.shift(); + if (entry) { + state.activeRoute = entry.route; + state.activeAcks = entry.acks; + console.log( + `[Channels] agent_start: conversation=${conversationId} replyTo=${entry.route.deliveryCtx.replyToMessageId}, acks=${entry.acks.length}`, + ); + } + } + + // Agent run complete — remove 👀 from all batch messages, conditionally stop typing. + if ((event as { type?: string }).type === "agent_end") { + for (const ack of state.activeAcks) { + if (ack.plugin.outbound.removeReaction) { + console.log(`[Channels] agent_end: removing 👀 from replyTo=${ack.deliveryCtx.replyToMessageId}`); + void ack.plugin.outbound.removeReaction(ack.deliveryCtx).catch(() => {}); + } + } + state.activeRoute = null; + state.activeAcks = []; + state.statusMessageId = null; + if (state.pendingRoutes.length === 0) { + console.log(`[Channels] agent_end: conversation=${conversationId}, no more pending, stopping typing`); + this.stopTypingForConversation(conversationId); + } else { + console.log( + `[Channels] agent_end: conversation=${conversationId}, ${state.pendingRoutes.length} pending run(s), keeping typing`, + ); + } + } + + const route = state.activeRoute ?? this.findRouteForConversation(conversationId) ?? this.lastRoute; if (!route) return; + // Handle agent errors — notify the channel user + if ((event as { type?: string }).type === "agent_error") { + this.stopTypingForConversation(conversationId); + for (const ack of state.activeAcks) { + if (ack.plugin.outbound.removeReaction) { + void ack.plugin.outbound.removeReaction(ack.deliveryCtx).catch(() => {}); + } + } + state.activeRoute = null; + state.activeAcks = []; + state.statusMessageId = null; + const errorMsg = (event as { message?: string }).message ?? "Unknown error"; + console.error(`[Channels] Agent error: ${errorMsg}`); + void route.plugin.outbound.sendText(route.deliveryCtx, `[Error] ${errorMsg}`).catch((err) => { + console.error(`[Channels] Failed to send error to channel: ${err}`); + }); + return; + } + + const eventType = (event as { type?: string }).type; + + // Only forward assistant message events. + if (eventType === "message_start" || eventType === "message_update" || eventType === "message_end") { + if (role !== "assistant") return; + } else { + // Non-message events (tool_execution etc.) — skip for channels. + return; + } + + // Keep heartbeat acknowledgements internal (same behavior as desktop/gateway stream path). + if (isHeartbeatAckEvent(event)) { + if (eventType === "message_end") { + state.aggregator = null; + } + return; + } + + // Ensure aggregator exists for this response. + if (eventType === "message_start") { + this.createAggregator(conversationId, this.cloneRoute(route), state); + } + + // Tool narration: if the assistant message contains tool_use blocks, + // send/edit an editable status message instead of normal reply flow. + if (eventType === "message_end" && role === "assistant") { + const message = (event as { message?: Parameters[0] }).message; + if (hasToolUse(message)) { + state.aggregator?.reset(); + state.aggregator = null; + + const narration = extractText(message as Parameters[0]); + if (narration) { + void this.sendOrEditStatus(conversationId, route, narration); + } + return; + } + } + + if (state.aggregator) { + state.aggregator.handleEvent(event as Parameters[0]); + } + + // Finalize aggregator per assistant message. + if (eventType === "message_end" && role === "assistant") { + state.aggregator = null; + } + } + + private createAggregator(conversationId: string, route: LastRoute, state: ConversationState): void { const { plugin, deliveryCtx } = route; - console.log(`[Channels] createAggregator: replyTo=${deliveryCtx.replyToMessageId} (source=${this.activeRoute ? "activeRoute" : "lastRoute"})`); + console.log( + `[Channels] createAggregator: conversation=${conversationId} replyTo=${deliveryCtx.replyToMessageId}`, + ); const chunkerConfig = plugin.chunkerConfig ?? DEFAULT_CHUNKER_CONFIG; - this.aggregator = new MessageAggregator( + state.aggregator = new MessageAggregator( chunkerConfig, async (block) => { try { - console.log(`[Channels] Sending block ${block.index} (${block.text.length} chars${block.isFinal ? ", final" : ""}) → ${deliveryCtx.channel}:${deliveryCtx.conversationId} replyTo=${deliveryCtx.replyToMessageId}`); + console.log( + `[Channels] Sending block ${block.index} (${block.text.length} chars${block.isFinal ? ", final" : ""}) -> ${deliveryCtx.channel}:${deliveryCtx.conversationId} replyTo=${deliveryCtx.replyToMessageId}`, + ); if (block.index === 0) { await plugin.outbound.replyText(deliveryCtx, block.text); } else { @@ -345,34 +559,29 @@ export class ChannelManager { ); } - /** - * Send or edit a status message for tool narration. - * First call sends a new editable reply; subsequent calls edit the same message. - * Falls back to no-op if the plugin doesn't support editable messages. - */ private async sendOrEditStatus( - plugin: ChannelPlugin, - deliveryCtx: DeliveryContext, + conversationId: string, + route: LastRoute, text: string, ): Promise { + const state = this.getConversationState(conversationId); + try { - if (this.statusMessageId && plugin.outbound.editText) { - console.log(`[Channels] Editing status message ${this.statusMessageId}`); - await plugin.outbound.editText(deliveryCtx, this.statusMessageId, text); - } else if (plugin.outbound.replyTextEditable) { - const msgId = await plugin.outbound.replyTextEditable(deliveryCtx, text); - this.statusMessageId = msgId; + if (state.statusMessageId && route.plugin.outbound.editText) { + console.log(`[Channels] Editing status message ${state.statusMessageId}`); + await route.plugin.outbound.editText(route.deliveryCtx, state.statusMessageId, text); + } else if (route.plugin.outbound.replyTextEditable) { + const msgId = await route.plugin.outbound.replyTextEditable(route.deliveryCtx, text); + state.statusMessageId = msgId; console.log(`[Channels] Sent editable status message ${msgId}`); } - // If plugin doesn't support editable messages, silently skip (typing indicator still active) + // If plugin doesn't support editable messages, silently skip. } catch (err) { console.error(`[Channels] Failed to send/edit status: ${err}`); } } - /** - * Incoming channel message → update lastRoute → forward to Hub's agent. - */ + /** Incoming channel message -> routeKey binding -> Hub conversation write. */ private routeIncoming( plugin: ChannelPlugin, accountId: string, @@ -383,162 +592,190 @@ export class ChannelManager { `[Channels] Incoming: channel=${plugin.id} conv=${conversationId} sender=${senderId} text="${text.slice(0, 50)}${text.length > 50 ? "..." : ""}"`, ); - const agent = this.getHubAgent(); - if (!agent) { - console.error("[Channels] No agent available, dropping message"); + const resolved = this.resolveOrCreateRouteBinding( + plugin, + accountId, + conversationId, + messageId, + message.chatType, + ); + if (!resolved) { + console.error("[Channels] Failed to resolve conversation route, dropping message"); return; } - // Ensure we're subscribed to this agent (handles late startup / agent change) - this.ensureSubscribed(); - - // Update last route — replies will go back here - this.lastRoute = { - plugin, - deliveryCtx: { - channel: plugin.id, - accountId, - conversationId, - replyToMessageId: messageId, - }, - chatType: message.chatType, - }; - console.log(`[Channels] lastRoute updated → ${plugin.id}:${conversationId} replyTo=${messageId}`); - console.log(`[Channels] Forwarding to agent ${agent.sessionId}`); - - // Show typing indicator and 👀 ack on this message - this.startTyping(); - const ackRoute: LastRoute = { ...this.lastRoute }; - if (ackRoute.plugin.outbound.addReaction) { - console.log(`[Channels] Adding 👀 to replyTo=${messageId}`); - void ackRoute.plugin.outbound.addReaction(ackRoute.deliveryCtx, "👀").catch(() => {}); + const { binding, conversation } = resolved; + if (!binding.lastRoute) { + console.error(`[Channels] Route binding missing runtime route data for ${binding.routeKey}`); + return; } - this.ackBuffer.push(ackRoute); + this.ensureConversationSubscribed(conversation); - // Handle media messages (processed async, then fed through debouncer) + const routeSnapshot = this.cloneRoute(binding.lastRoute); + this.lastRoute = routeSnapshot; + const state = this.getConversationState(binding.hubConversationId); + + console.log( + `[Channels] route selected: ${binding.routeKey} -> conversation=${binding.hubConversationId} (agent=${binding.hubAgentId})`, + ); + + // Show typing indicator and 👀 ack on this message. + this.startTypingForRoute(routeSnapshot); + if (routeSnapshot.plugin.outbound.addReaction) { + console.log(`[Channels] Adding 👀 to replyTo=${messageId}`); + void routeSnapshot.plugin.outbound.addReaction(routeSnapshot.deliveryCtx, "👀").catch(() => {}); + } + state.ackBuffer.push(routeSnapshot); + + // Handle media messages (processed async, then fed through debouncer). if (message.media && plugin.downloadMedia) { - void this.routeMedia(plugin, accountId, message, agent); + void this.routeMedia(plugin, accountId, message, binding.routeKey); } else { - // Text messages go through debouncer to batch rapid-fire sends - this.getDebouncer(agent).push(conversationId, text); + // Text messages go through debouncer to batch rapid-fire sends. + this.getDebouncer().push(binding.routeKey, text); } } /** * Download media file, process it (transcribe/describe), and forward - * the resulting text through the debouncer to the agent. - * Media results are also debounced so that a rapid "photo + text" combo - * from the same conversation gets batched into one agent prompt. + * the resulting text through the debouncer. */ private async routeMedia( plugin: ChannelPlugin, accountId: string, message: ChannelMessage, - agent: AsyncAgent, + routeKey: string, ): Promise { const media = message.media!; - const debouncer = this.getDebouncer(agent); + const debouncer = this.getDebouncer(); try { const filePath = await plugin.downloadMedia!(media.fileId, accountId); if (media.type === "image") { - // Images: describe via Vision API before reaching agent + // Images: describe via Vision API before reaching agent. const description = await describeImage(filePath); if (description) { const parts = ["[Image]", `Description: ${description}`]; if (media.caption) parts.push(`Caption: ${media.caption}`); - debouncer.push(message.conversationId, parts.join("\n")); + debouncer.push(routeKey, parts.join("\n")); } else { - // No API key — fall back to file path + // No API key — fall back to file path. const parts = ["[image message received]", `File: ${filePath}`]; if (media.caption) parts.push(`Caption: ${media.caption}`); - debouncer.push(message.conversationId, parts.join("\n")); + debouncer.push(routeKey, parts.join("\n")); } } else if (media.type === "audio") { - // Audio: transcribe via Whisper API before reaching agent + // Audio: transcribe via Whisper API before reaching agent. const transcript = await transcribeAudio(filePath); if (transcript) { const parts = ["[Voice Message]", `Transcript: ${transcript}`]; if (media.caption) parts.push(`Caption: ${media.caption}`); - debouncer.push(message.conversationId, parts.join("\n")); + debouncer.push(routeKey, parts.join("\n")); } else { - // No API key configured — fall back to file path + // No API key configured — fall back to file path. const parts = ["[audio message received]", `File: ${filePath}`]; if (media.mimeType) parts.push(`Type: ${media.mimeType}`); if (media.duration) parts.push(`Duration: ${media.duration}s`); if (media.caption) parts.push(`Caption: ${media.caption}`); - debouncer.push(message.conversationId, parts.join("\n")); + debouncer.push(routeKey, parts.join("\n")); } } else if (media.type === "video") { - // Video: extract frame + describe via Vision API + // Video: extract frame + describe via Vision API. const description = await describeVideo(filePath); if (description) { const parts = ["[Video]", `Description: ${description}`]; if (media.duration) parts.push(`Duration: ${media.duration}s`); if (media.caption) parts.push(`Caption: ${media.caption}`); - debouncer.push(message.conversationId, parts.join("\n")); + debouncer.push(routeKey, parts.join("\n")); } else { - // ffmpeg unavailable or no API key — fall back to file path + // ffmpeg unavailable or no API key — fall back to file path. const parts = ["[video message received]", `File: ${filePath}`]; if (media.mimeType) parts.push(`Type: ${media.mimeType}`); if (media.duration) parts.push(`Duration: ${media.duration}s`); if (media.caption) parts.push(`Caption: ${media.caption}`); - debouncer.push(message.conversationId, parts.join("\n")); + debouncer.push(routeKey, parts.join("\n")); } } else { - // Document: tell agent the file path + // Document: tell agent the file path. const parts: string[] = []; - parts.push(`[document message received]`); + parts.push("[document message received]"); parts.push(`File: ${filePath}`); if (media.mimeType) parts.push(`Type: ${media.mimeType}`); if (media.caption) parts.push(`Caption: ${media.caption}`); - debouncer.push(message.conversationId, parts.join("\n")); + debouncer.push(routeKey, parts.join("\n")); } } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error(`[Channels] Failed to process media: ${msg}`); - debouncer.push(message.conversationId, message.text || `[Failed to process ${media.type}]`); + debouncer.push(routeKey, message.text || `[Failed to process ${media.type}]`); } } /** - * Get or create the inbound debouncer, wired to the given agent. - * The debouncer batches rapid-fire messages by conversationId, then - * calls agent.write() once with the combined text. + * Get or create inbound debouncer. + * Batches rapid-fire messages by routeKey, then writes once to the bound Hub conversation. */ - private getDebouncer(agent: AsyncAgent): InboundDebouncer { + private getDebouncer(): InboundDebouncer { if (!this.debouncer) { this.debouncer = new InboundDebouncer( - (_conversationId, combinedText) => { - // Snapshot the current route + pending acks for this batch. - const route = this.lastRoute ? { ...this.lastRoute } : null; - const acks = [...this.ackBuffer]; - this.ackBuffer = []; - const source = route ? { + (routeKey, combinedText) => { + const binding = this.routeBindings.get(routeKey); + if (!binding) { + console.warn(`[Channels] Debouncer flush dropped: unknown routeKey=${routeKey}`); + return; + } + + const conversation = this.hub.getConversation(binding.hubConversationId); + if (!conversation || conversation.closed) { + console.warn( + `[Channels] Debouncer flush dropped: conversation unavailable ${binding.hubConversationId}`, + ); + this.routeBindings.delete(routeKey); + this.persistRouteBindings(); + this.cleanupConversationState(binding.hubConversationId, { unsubscribe: true }); + return; + } + + this.ensureConversationSubscribed(conversation); + + if (!binding.lastRoute) { + console.warn(`[Channels] Debouncer flush dropped: missing lastRoute for routeKey=${routeKey}`); + return; + } + const state = this.getConversationState(binding.hubConversationId); + const route = this.cloneRoute(binding.lastRoute); + const acks = [...state.ackBuffer]; + state.ackBuffer = []; + + state.pendingRoutes.push({ route, acks }); + + const source = { type: "channel" as const, channelId: route.plugin.id, accountId: route.deliveryCtx.accountId, conversationId: route.deliveryCtx.conversationId, - } : undefined; - if (route) { - this.pendingRoutes.push({ route, acks }); - // Broadcast inbound message to local listeners (Desktop UI) - this.hub.broadcastInbound({ - agentId: agent.sessionId, - content: combinedText, - source: source!, - timestamp: Date.now(), - }); - } - // Prepend source context so the LLM knows which platform/chat type the message came from - const channelName = route?.plugin.meta.name ?? "Channel"; - const chatLabel = route?.chatType === "group" ? "group" : "private"; + }; + + // Broadcast inbound message to local listeners (Desktop UI). + this.hub.broadcastInbound({ + agentId: binding.hubAgentId, + conversationId: binding.hubConversationId, + content: combinedText, + source, + timestamp: Date.now(), + }); + + // Prepend source context so the LLM knows platform + chat type. + const channelName = route.plugin.meta.name ?? "Channel"; + const chatLabel = route.chatType === "group" ? "group" : "private"; const prefixedText = `[${channelName} · ${chatLabel}]\n${combinedText}`; - const replyTo = route?.deliveryCtx.replyToMessageId ?? "?"; - console.log(`[Channels] Debouncer flushing ${combinedText.length} chars to agent (queued route replyTo=${replyTo}, acks=${acks.length})`); - agent.write(prefixedText, { source }); + const replyTo = route.deliveryCtx.replyToMessageId ?? "?"; + console.log( + `[Channels] Debouncer flushing ${combinedText.length} chars to conversation=${binding.hubConversationId} (route replyTo=${replyTo}, acks=${acks.length})`, + ); + conversation.write(prefixedText, { source }); }, ); } @@ -546,11 +783,22 @@ export class ChannelManager { } /** - * Send a file to the active channel conversation. + * Send a file to the active channel route. * Returns true if the file was sent, false if no active route or plugin doesn't support media. */ async sendFile(filePath: string, caption?: string, type?: string): Promise { - const route = this.activeRoute ?? this.lastRoute; + let route: LastRoute | null = null; + + for (const state of this.conversationStates.values()) { + if (state.activeRoute) { + route = state.activeRoute; + break; + } + } + + if (!route) { + route = this.lastRoute; + } if (!route) return false; const { plugin, deliveryCtx } = route; @@ -583,53 +831,49 @@ export class ChannelManager { return "document"; } - /** Start sending typing indicators (repeats every 5s until stopped) */ - private startTyping(): void { - this.stopTyping(); - const route = this.lastRoute; - if (!route?.plugin.outbound.sendTyping) return; - - const send = () => route.plugin.outbound.sendTyping!(route.deliveryCtx).catch(() => {}); - void send(); - this.typingTimer = setInterval(send, 5000); - } - - /** Stop typing indicator interval */ - private stopTyping(): void { - if (this.typingTimer) { - clearInterval(this.typingTimer); - this.typingTimer = null; - } - } - /** * Stop a specific channel account. * Public so the desktop IPC layer can call it when removing config. - * Cleans up typing timer, debouncer, aggregator, and lastRoute if they - * belong to this account. */ stopAccount(channelId: string, accountId: string): void { const key = `${channelId}:${accountId}`; const handle = this.accounts.get(key); if (!handle) return; - // Clean up shared resources if they target this account - if (this.lastRoute && this.lastRoute.plugin.id === channelId && this.lastRoute.deliveryCtx.accountId === accountId) { - this.stopTyping(); + const removedConversationIds = new Set(); + for (const [routeKey, binding] of this.routeBindings.entries()) { + const route = binding.lastRoute; + const matchesByRoute = route + ? route.plugin.id === channelId && route.deliveryCtx.accountId === accountId + : routeKey.startsWith(`${channelId}:${accountId}:`); + if (matchesByRoute) { + this.routeBindings.delete(routeKey); + removedConversationIds.add(binding.hubConversationId); + } + } + this.persistRouteBindings(); + + for (const conversationId of removedConversationIds) { + const stillBound = Array.from(this.routeBindings.values()) + .some((binding) => binding.hubConversationId === conversationId); + if (!stillBound) { + this.cleanupConversationState(conversationId, { unsubscribe: true }); + } + } + + if ( + this.lastRoute + && this.lastRoute.plugin.id === channelId + && this.lastRoute.deliveryCtx.accountId === accountId + ) { + this.stopTypingForConversation(this.lastRoute.hubConversationId); this.lastRoute = null; - this.activeRoute = null; - this.activeAcks = []; - this.ackBuffer = []; - this.pendingRoutes = []; - this.aggregator = null; - this.statusMessageId = null; } handle.abortController.abort(); handle.state = { ...handle.state, status: "stopped" }; this.accounts.delete(key); - // Dispose debouncer if no accounts remain if (this.accounts.size === 0 && this.debouncer) { this.debouncer.dispose(); this.debouncer = null; @@ -641,32 +885,35 @@ export class ChannelManager { /** Stop all running channel accounts */ stopAll(): void { console.log("[Channels] Stopping all channels..."); - this.stopTyping(); + this.debouncer?.dispose(); this.debouncer = null; - if (this.agentUnsubscribe) { - this.agentUnsubscribe(); - this.agentUnsubscribe = null; + + for (const unsubscribe of this.conversationSubscriptions.values()) { + unsubscribe(); } + this.conversationSubscriptions.clear(); + + for (const conversationId of this.conversationStates.keys()) { + this.stopTypingForConversation(conversationId); + } + this.conversationStates.clear(); + for (const [key, handle] of this.accounts) { handle.abortController.abort(); handle.state = { ...handle.state, status: "stopped" }; console.log(`[Channels] Stopped ${key}`); } + this.accounts.clear(); + this.routeBindings.clear(); this.lastRoute = null; - this.activeRoute = null; - this.activeAcks = []; - this.ackBuffer = []; - this.pendingRoutes = []; - this.aggregator = null; - this.statusMessageId = null; } /** Clear the last route (e.g. when desktop user sends a message) */ clearLastRoute(): void { if (this.lastRoute) { - this.stopTyping(); + this.stopTypingForConversation(this.lastRoute.hubConversationId); console.log("[Channels] lastRoute cleared (non-channel message received)"); this.lastRoute = null; } @@ -693,4 +940,56 @@ export class ChannelManager { } return infos; } + + private loadRouteBindings(): void { + if (!existsSync(this.routeBindingsPath)) return; + + try { + const raw = JSON.parse(readFileSync(this.routeBindingsPath, "utf-8")) as RouteBindingStoreFile; + const bindings = Array.isArray(raw.bindings) ? raw.bindings : []; + for (const item of bindings) { + if (!item || typeof item !== "object") continue; + if (typeof item.routeKey !== "string" || !item.routeKey.trim()) continue; + if (typeof item.hubConversationId !== "string" || !item.hubConversationId.trim()) continue; + if (typeof item.hubAgentId !== "string" || !item.hubAgentId.trim()) continue; + const routeKey = item.routeKey.trim(); + this.routeBindings.set(routeKey, { + routeKey, + hubConversationId: item.hubConversationId.trim(), + hubAgentId: item.hubAgentId.trim(), + updatedAt: typeof item.updatedAt === "number" ? item.updatedAt : Date.now(), + lastRoute: null, + }); + } + if (this.routeBindings.size > 0) { + console.log(`[Channels] Restored ${this.routeBindings.size} route binding(s) from disk`); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.warn(`[Channels] Failed to load route bindings: ${message}`); + } + } + + private persistRouteBindings(): void { + const serialized: RouteBindingStoreFile = { + version: ROUTE_BINDING_STORE_VERSION, + bindings: Array.from(this.routeBindings.values()) + .map((binding) => ({ + routeKey: binding.routeKey, + hubConversationId: binding.hubConversationId, + hubAgentId: binding.hubAgentId, + updatedAt: binding.updatedAt, + })) + .sort((a, b) => a.routeKey.localeCompare(b.routeKey)), + }; + + try { + const dir = dirname(this.routeBindingsPath); + mkdirSync(dir, { recursive: true }); + writeFileSync(this.routeBindingsPath, JSON.stringify(serialized, null, 2), "utf-8"); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.warn(`[Channels] Failed to persist route bindings: ${message}`); + } + } } diff --git a/packages/core/src/client/actions/exec-approval.ts b/packages/core/src/client/actions/exec-approval.ts index cd098273..0b314d77 100644 --- a/packages/core/src/client/actions/exec-approval.ts +++ b/packages/core/src/client/actions/exec-approval.ts @@ -14,6 +14,8 @@ export interface ExecApprovalRequestPayload { approvalId: string; /** Agent that initiated the command */ agentId: string; + /** Conversation ID that initiated the approval request. */ + conversationId: string; /** Shell command requiring approval */ command: string; /** Working directory */ diff --git a/packages/core/src/client/actions/index.ts b/packages/core/src/client/actions/index.ts index b3ef94e3..b1193cac 100644 --- a/packages/core/src/client/actions/index.ts +++ b/packages/core/src/client/actions/index.ts @@ -19,11 +19,11 @@ export { type GetAgentMessagesParams, type GetAgentMessagesResult, type GetHubInfoResult, - type ListAgentsResult, - type CreateAgentParams, - type CreateAgentResult, - type DeleteAgentParams, - type DeleteAgentResult, + type ListConversationsResult, + type CreateConversationParams, + type CreateConversationResult, + type DeleteConversationParams, + type DeleteConversationResult, type UpdateGatewayParams, type UpdateGatewayResult, type DeviceMeta, diff --git a/packages/core/src/client/actions/rpc.ts b/packages/core/src/client/actions/rpc.ts index ddbc78f7..52dcc133 100644 --- a/packages/core/src/client/actions/rpc.ts +++ b/packages/core/src/client/actions/rpc.ts @@ -66,6 +66,8 @@ export const DEFAULT_MESSAGES_LIMIT = 200; /** getAgentMessages - request params */ export interface GetAgentMessagesParams { agentId: string; + /** Conversation ID to read. */ + conversationId: string; offset?: number; limit?: number; } @@ -82,6 +84,10 @@ export interface GetAgentMessagesResult { total: number; offset: number; limit: number; + /** Conversation ID used by the server. */ + conversationId: string; + /** Context window size (tokens) used by this session */ + contextWindowTokens?: number; } /** getHubInfo - no params needed */ @@ -92,28 +98,29 @@ export interface GetHubInfoResult { agentCount: number; } -/** listAgents - no params needed */ -export interface ListAgentsResult { - agents: { id: string; closed: boolean }[]; +/** listConversations - no params needed */ +export interface ListConversationsResult { + conversations: { id: string; closed: boolean }[]; } -/** createAgent - request params */ -export interface CreateAgentParams { +/** createConversation - request params (create a conversation, optionally under a specific agent) */ +export interface CreateConversationParams { id?: string; + agentId?: string; } -/** createAgent - response payload */ -export interface CreateAgentResult { +/** createConversation - response payload */ +export interface CreateConversationResult { id: string; } -/** deleteAgent - request params */ -export interface DeleteAgentParams { +/** deleteConversation - request params */ +export interface DeleteConversationParams { id: string; } -/** deleteAgent - response payload */ -export interface DeleteAgentResult { +/** deleteConversation - response payload */ +export interface DeleteConversationResult { ok: boolean; } @@ -145,4 +152,6 @@ export interface VerifyParams { export interface VerifyResult { hubId: string; agentId: string; + /** Authorized conversation scope for this device. */ + conversationId: string; } diff --git a/packages/core/src/client/actions/stream.ts b/packages/core/src/client/actions/stream.ts index cf1349d3..777b9734 100644 --- a/packages/core/src/client/actions/stream.ts +++ b/packages/core/src/client/actions/stream.ts @@ -62,6 +62,8 @@ export type AgentErrorEvent = { export interface StreamPayload { streamId: string; agentId: string; + /** Conversation ID of this stream event. */ + conversationId: string; event: AgentEvent | CompactionEvent | AgentErrorEvent; } diff --git a/packages/core/src/client/client.ts b/packages/core/src/client/client.ts index cf7bf932..b94b3a64 100644 --- a/packages/core/src/client/client.ts +++ b/packages/core/src/client/client.ts @@ -234,7 +234,16 @@ export class GatewayClient { } /** Hub 验证成功回调 */ - onVerified(callback: (result: { hubId: string; agentId: string; isNewDevice?: boolean }) => void): this { + onVerified( + callback: ( + result: { + hubId: string; + agentId: string; + conversationId: string; + isNewDevice?: boolean; + } + ) => void, + ): this { this.callbacks.onVerified = callback; return this; } @@ -318,12 +327,17 @@ export class GatewayClient { platform: navigator.platform, language: navigator.language, } : undefined; - this.request<{ hubId: string; agentId: string; isNewDevice?: boolean }>( - this.options.hubId, - "verify", - { token: this.options.token, meta }, - this.options.verifyTimeout, - ) + this.request<{ + hubId: string; + agentId: string; + conversationId: string; + isNewDevice?: boolean; + }>( + this.options.hubId, + "verify", + { token: this.options.token, meta }, + this.options.verifyTimeout, + ) .then((result) => { // Verify succeeded — now expose "registered" to upper layer this.callbacks.onVerified?.(result); diff --git a/packages/core/src/client/types.ts b/packages/core/src/client/types.ts index f8a3f693..9a2a1320 100644 --- a/packages/core/src/client/types.ts +++ b/packages/core/src/client/types.ts @@ -110,7 +110,14 @@ export interface GatewayClientCallbacks { onConnect?: (socketId: string) => void; onDisconnect?: (reason: string) => void; onRegistered?: (deviceId: string) => void; - onVerified?: (result: { hubId: string; agentId: string; isNewDevice?: boolean }) => void; + onVerified?: ( + result: { + hubId: string; + agentId: string; + conversationId: string; + isNewDevice?: boolean; + } + ) => void; onMessage?: (message: RoutedMessage) => void; onSendError?: (error: SendErrorResponse) => void; onPong?: (data: string) => void; diff --git a/packages/core/src/hub/agent-store.test.ts b/packages/core/src/hub/agent-store.test.ts new file mode 100644 index 00000000..e6d8bc9f --- /dev/null +++ b/packages/core/src/hub/agent-store.test.ts @@ -0,0 +1,80 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("hub agent store", () => { + let testDir: string; + let previousDataDir: string | undefined; + + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), "multica-agent-store-")); + previousDataDir = process.env.SMC_DATA_DIR; + process.env.SMC_DATA_DIR = testDir; + vi.resetModules(); + }); + + afterEach(() => { + if (previousDataDir === undefined) { + delete process.env.SMC_DATA_DIR; + } else { + process.env.SMC_DATA_DIR = previousDataDir; + } + vi.resetModules(); + rmSync(testDir, { recursive: true, force: true }); + }); + + it("migrates legacy single-layer records into agent+conversation snapshot", async () => { + const agentsDir = join(testDir, "agents"); + mkdirSync(agentsDir, { recursive: true }); + writeFileSync( + join(agentsDir, "agents.json"), + JSON.stringify([ + { id: "legacy-a", createdAt: 123 }, + ], null, 2), + "utf-8", + ); + + const store = await import("./agent-store.js"); + const snapshot = store.loadHubStoreSnapshot(); + + expect(snapshot.version).toBe(2); + expect(snapshot.agents).toEqual([{ id: "legacy-a", createdAt: 123 }]); + expect(snapshot.conversations).toEqual([{ id: "legacy-a", agentId: "legacy-a", createdAt: 123 }]); + + const persisted = JSON.parse(readFileSync(join(agentsDir, "agents.json"), "utf-8")) as { + version: number; + }; + expect(persisted.version).toBe(2); + }); + + it("upserts conversations and auto-creates missing agents", async () => { + const store = await import("./agent-store.js"); + + store.upsertConversationRecord({ + id: "conv-1", + agentId: "agent-1", + createdAt: 100, + }); + + const snapshot = store.loadHubStoreSnapshot(); + expect(snapshot.agents).toEqual([{ id: "agent-1", createdAt: 100 }]); + expect(snapshot.conversations).toEqual([ + { id: "conv-1", agentId: "agent-1", createdAt: 100 }, + ]); + }); + + it("removes empty agent after last conversation is deleted", async () => { + const store = await import("./agent-store.js"); + + store.upsertConversationRecord({ id: "conv-1", agentId: "agent-1", createdAt: 100 }); + store.upsertConversationRecord({ id: "conv-2", agentId: "agent-1", createdAt: 101 }); + store.removeConversationRecordById("conv-1"); + expect(store.loadHubStoreSnapshot().agents).toEqual([{ id: "agent-1", createdAt: 100 }]); + + store.removeConversationRecordById("conv-2"); + const snapshot = store.loadHubStoreSnapshot(); + expect(snapshot.agents).toEqual([]); + expect(snapshot.conversations).toEqual([]); + }); +}); diff --git a/packages/core/src/hub/agent-store.ts b/packages/core/src/hub/agent-store.ts index 832879ae..c697ac85 100644 --- a/packages/core/src/hub/agent-store.ts +++ b/packages/core/src/hub/agent-store.ts @@ -5,10 +5,34 @@ import { DATA_DIR } from "@multica/utils"; export interface AgentRecord { id: string; createdAt: number; + profileId?: string; +} + +export interface ConversationRecord { + id: string; + agentId: string; + createdAt: number; + profileId?: string; +} + +export interface HubStoreSnapshot { + version: 2; + agents: AgentRecord[]; + conversations: ConversationRecord[]; } const AGENTS_DIR = join(DATA_DIR, "agents"); const AGENTS_FILE = join(AGENTS_DIR, "agents.json"); +const warnedLegacyApis = new Set(); + +function warnLegacyApi(apiName: string): void { + if (warnedLegacyApis.has(apiName)) return; + warnedLegacyApis.add(apiName); + console.warn( + `[agent-store] Deprecated legacy API "${apiName}" was used. ` + + "Migrate callers to conversation-first APIs (loadHubStoreSnapshot/upsertConversationRecord).", + ); +} function ensureDir(): void { if (!existsSync(AGENTS_DIR)) { @@ -16,32 +40,244 @@ function ensureDir(): void { } } -export function loadAgentRecords(): AgentRecord[] { - if (!existsSync(AGENTS_FILE)) return []; +function defaultSnapshot(): HubStoreSnapshot { + return { + version: 2, + agents: [], + conversations: [], + }; +} + +function isRecordLike(value: unknown): value is Record { + return !!value && typeof value === "object"; +} + +function normalizeCreatedAt(raw: unknown): number { + if (typeof raw === "number" && Number.isFinite(raw)) { + return raw; + } + return Date.now(); +} + +function normalizeAgentRecords(input: unknown): AgentRecord[] { + if (!Array.isArray(input)) return []; + const dedup = new Map(); + for (const item of input) { + if (!isRecordLike(item) || typeof item.id !== "string" || !item.id.trim()) continue; + const id = item.id.trim(); + if (dedup.has(id)) continue; + dedup.set(id, { + id, + createdAt: normalizeCreatedAt(item.createdAt), + ...(typeof item.profileId === "string" && item.profileId.trim() ? { profileId: item.profileId.trim() } : {}), + }); + } + return Array.from(dedup.values()).sort((a, b) => a.createdAt - b.createdAt); +} + +function normalizeConversationRecords(input: unknown): ConversationRecord[] { + if (!Array.isArray(input)) return []; + const dedup = new Map(); + for (const item of input) { + if (!isRecordLike(item) || typeof item.id !== "string" || !item.id.trim()) continue; + if (typeof item.agentId !== "string" || !item.agentId.trim()) continue; + const id = item.id.trim(); + if (dedup.has(id)) continue; + dedup.set(id, { + id, + agentId: item.agentId.trim(), + createdAt: normalizeCreatedAt(item.createdAt), + ...(typeof item.profileId === "string" && item.profileId.trim() ? { profileId: item.profileId.trim() } : {}), + }); + } + return Array.from(dedup.values()).sort((a, b) => a.createdAt - b.createdAt); +} + +function normalizeSnapshot(raw: unknown): { snapshot: HubStoreSnapshot; migrated: boolean } { + // Legacy format: AgentRecord[] + if (Array.isArray(raw)) { + const legacyAgents = normalizeAgentRecords(raw); + const conversations: ConversationRecord[] = legacyAgents.map((record) => ({ + id: record.id, + agentId: record.id, + createdAt: record.createdAt, + ...(record.profileId ? { profileId: record.profileId } : {}), + })); + return { + snapshot: { + version: 2, + agents: legacyAgents, + conversations, + }, + migrated: true, + }; + } + + if (!isRecordLike(raw)) { + return { snapshot: defaultSnapshot(), migrated: false }; + } + + const agents = normalizeAgentRecords(raw.agents); + const conversations = normalizeConversationRecords(raw.conversations); + const agentMap = new Map(agents.map((agent) => [agent.id, agent])); + const normalizedConversations: ConversationRecord[] = []; + + for (const conversation of conversations) { + if (!agentMap.has(conversation.agentId)) { + agentMap.set(conversation.agentId, { + id: conversation.agentId, + createdAt: conversation.createdAt, + ...(conversation.profileId ? { profileId: conversation.profileId } : {}), + }); + } + normalizedConversations.push(conversation); + } + + // Ensure each agent has a main conversation for compatibility fallback. + for (const agent of agentMap.values()) { + const hasConversation = normalizedConversations.some((conversation) => conversation.agentId === agent.id); + if (hasConversation) continue; + normalizedConversations.push({ + id: agent.id, + agentId: agent.id, + createdAt: agent.createdAt, + ...(agent.profileId ? { profileId: agent.profileId } : {}), + }); + } + + return { + snapshot: { + version: 2, + agents: Array.from(agentMap.values()).sort((a, b) => a.createdAt - b.createdAt), + conversations: normalizedConversations.sort((a, b) => a.createdAt - b.createdAt), + }, + migrated: (raw.version as unknown) !== 2, + }; +} + +export function saveHubStoreSnapshot(snapshot: HubStoreSnapshot): void { + ensureDir(); + writeFileSync(AGENTS_FILE, JSON.stringify(snapshot, null, 2), "utf-8"); +} + +export function loadHubStoreSnapshot(): HubStoreSnapshot { + if (!existsSync(AGENTS_FILE)) return defaultSnapshot(); try { const content = readFileSync(AGENTS_FILE, "utf-8"); - return JSON.parse(content) as AgentRecord[]; + const parsed = JSON.parse(content) as unknown; + const normalized = normalizeSnapshot(parsed); + if (normalized.migrated) { + saveHubStoreSnapshot(normalized.snapshot); + } + return normalized.snapshot; } catch { - return []; + return defaultSnapshot(); } } +export function upsertAgentRecord(record: AgentRecord): void { + const snapshot = loadHubStoreSnapshot(); + const existing = snapshot.agents.filter((item) => item.id !== record.id); + existing.push(record); + snapshot.agents = existing.sort((a, b) => a.createdAt - b.createdAt); + saveHubStoreSnapshot(snapshot); +} + +export function removeAgentRecordById(agentId: string): void { + const snapshot = loadHubStoreSnapshot(); + const agents = snapshot.agents.filter((agent) => agent.id !== agentId); + const conversations = snapshot.conversations.filter((conversation) => conversation.agentId !== agentId); + if (agents.length === snapshot.agents.length && conversations.length === snapshot.conversations.length) { + return; + } + saveHubStoreSnapshot({ + ...snapshot, + agents, + conversations, + }); +} + +export function upsertConversationRecord(record: ConversationRecord): void { + const snapshot = loadHubStoreSnapshot(); + const conversations = snapshot.conversations.filter((item) => item.id !== record.id); + conversations.push(record); + + const hasAgent = snapshot.agents.some((agent) => agent.id === record.agentId); + const agents = hasAgent + ? snapshot.agents + : [ + ...snapshot.agents, + { + id: record.agentId, + createdAt: record.createdAt, + ...(record.profileId ? { profileId: record.profileId } : {}), + }, + ]; + + saveHubStoreSnapshot({ + version: 2, + agents: agents.sort((a, b) => a.createdAt - b.createdAt), + conversations: conversations.sort((a, b) => a.createdAt - b.createdAt), + }); +} + +export function removeConversationRecordById(conversationId: string): void { + const snapshot = loadHubStoreSnapshot(); + const conversations = snapshot.conversations.filter((conversation) => conversation.id !== conversationId); + if (conversations.length === snapshot.conversations.length) { + return; + } + + const activeAgentIds = new Set(conversations.map((conversation) => conversation.agentId)); + const agents = snapshot.agents.filter((agent) => activeAgentIds.has(agent.id)); + saveHubStoreSnapshot({ + ...snapshot, + agents, + conversations, + }); +} + +// Legacy compatibility wrappers +// NOTE: In legacy mode, each agent record is treated as both agent and main conversation. +export function loadAgentRecords(): AgentRecord[] { + warnLegacyApi("loadAgentRecords"); + return loadHubStoreSnapshot().agents; +} + export function saveAgentRecords(records: AgentRecord[]): void { - ensureDir(); - writeFileSync(AGENTS_FILE, JSON.stringify(records, null, 2), "utf-8"); + warnLegacyApi("saveAgentRecords"); + const agents = normalizeAgentRecords(records); + const conversations = agents.map((record) => ({ + id: record.id, + agentId: record.id, + createdAt: record.createdAt, + ...(record.profileId ? { profileId: record.profileId } : {}), + })); + saveHubStoreSnapshot({ + version: 2, + agents, + conversations, + }); } export function addAgentRecord(record: AgentRecord): void { - const records = loadAgentRecords(); - if (records.some((r) => r.id === record.id)) return; - records.push(record); - saveAgentRecords(records); + warnLegacyApi("addAgentRecord"); + upsertAgentRecord(record); + upsertConversationRecord({ + id: record.id, + agentId: record.id, + createdAt: record.createdAt, + ...(record.profileId ? { profileId: record.profileId } : {}), + }); } export function removeAgentRecord(id: string): void { - const records = loadAgentRecords(); - const filtered = records.filter((r) => r.id !== id); - if (filtered.length !== records.length) { - saveAgentRecords(filtered); + warnLegacyApi("removeAgentRecord"); + // Legacy API accepts either agent id or conversation id. + const snapshot = loadHubStoreSnapshot(); + const conversation = snapshot.conversations.find((item) => item.id === id); + if (conversation) { + removeConversationRecordById(conversation.id); } + removeAgentRecordById(id); } diff --git a/packages/core/src/hub/device-store.test.ts b/packages/core/src/hub/device-store.test.ts new file mode 100644 index 00000000..298e5760 --- /dev/null +++ b/packages/core/src/hub/device-store.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { DeviceStore } from "./device-store.js"; + +describe("DeviceStore", () => { + const testDirs: string[] = []; + + afterEach(() => { + for (const dir of testDirs) { + rmSync(dir, { recursive: true, force: true }); + } + testDirs.length = 0; + }); + + it("stores token with conversation scope and enforces one-time consumption", () => { + const dir = mkdtempSync(join(tmpdir(), "device-store-test-")); + testDirs.push(dir); + const store = new DeviceStore({ devicesFile: join(dir, "whitelist.json") }); + + const expiresAt = Date.now() + 60_000; + store.registerToken("token-1", "agent-1", "conv-1", expiresAt); + + expect(store.consumeToken("token-1")).toEqual({ + agentId: "agent-1", + conversationId: "conv-1", + }); + expect(store.consumeToken("token-1")).toBeNull(); + }); + + it("enforces conversation-level authorization and supports adding scopes", () => { + const dir = mkdtempSync(join(tmpdir(), "device-store-test-")); + testDirs.push(dir); + const devicesFile = join(dir, "whitelist.json"); + const store = new DeviceStore({ devicesFile }); + + store.allowDevice("dev-1", "agent-1", "conv-1"); + expect(store.isAllowed("dev-1")).toEqual({ + agentId: "agent-1", + conversationIds: ["conv-1"], + }); + expect(store.isAllowed("dev-1", "conv-1")).toEqual({ + agentId: "agent-1", + conversationIds: ["conv-1"], + }); + expect(store.isAllowed("dev-1", "conv-2")).toBeNull(); + + expect(store.allowConversation("dev-1", "conv-2")).toBe(true); + expect(store.isAllowed("dev-1", "conv-2")).toEqual({ + agentId: "agent-1", + conversationIds: ["conv-1", "conv-2"], + }); + + const restored = new DeviceStore({ devicesFile }); + expect(restored.isAllowed("dev-1", "conv-1")).not.toBeNull(); + expect(restored.isAllowed("dev-1", "conv-2")).not.toBeNull(); + }); + + it("migrates legacy entries without conversationIds using agentId as fallback scope", () => { + const dir = mkdtempSync(join(tmpdir(), "device-store-test-")); + testDirs.push(dir); + const devicesFile = join(dir, "whitelist.json"); + writeFileSync( + devicesFile, + JSON.stringify({ + version: 1, + devices: [ + { + deviceId: "legacy-dev", + agentId: "legacy-agent", + addedAt: 123, + }, + ], + }), + "utf-8", + ); + + const store = new DeviceStore({ devicesFile }); + expect(store.isAllowed("legacy-dev")).toEqual({ + agentId: "legacy-agent", + conversationIds: ["legacy-agent"], + }); + expect(store.isAllowed("legacy-dev", "legacy-agent")).not.toBeNull(); + }); +}); + diff --git a/packages/core/src/hub/device-store.ts b/packages/core/src/hub/device-store.ts index ca2656af..7a5d4ccb 100644 --- a/packages/core/src/hub/device-store.ts +++ b/packages/core/src/hub/device-store.ts @@ -1,5 +1,5 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { DATA_DIR } from "@multica/utils"; // ============ Types ============ @@ -7,6 +7,7 @@ import { DATA_DIR } from "@multica/utils"; interface TokenEntry { token: string; agentId: string; + conversationId: string; expiresAt: number; } @@ -20,6 +21,7 @@ export interface DeviceMeta { export interface DeviceEntry { deviceId: string; agentId: string; + conversationIds: string[]; addedAt: number; meta?: DeviceMeta | undefined; } @@ -34,41 +36,44 @@ interface WhitelistFile { const DEVICES_DIR = join(DATA_DIR, "client-devices"); const DEVICES_FILE = join(DEVICES_DIR, "whitelist.json"); -function ensureDir(): void { - if (!existsSync(DEVICES_DIR)) { - mkdirSync(DEVICES_DIR, { recursive: true }); - } +interface DeviceStoreOptions { + devicesFile?: string; } -function loadDevices(): DeviceEntry[] { - if (!existsSync(DEVICES_FILE)) return []; - try { - const raw = JSON.parse(readFileSync(DEVICES_FILE, "utf-8")); - // Migrate legacy array format - if (Array.isArray(raw)) return raw as DeviceEntry[]; - return (raw as WhitelistFile).devices ?? []; - } catch { - return []; +function normalizeConversationIds( + input: unknown, + fallbackConversationId: string, +): string[] { + const ids = new Set(); + if (Array.isArray(input)) { + for (const item of input) { + if (typeof item !== "string") continue; + const id = item.trim(); + if (id) ids.add(id); + } } -} - -function saveDevices(devices: DeviceEntry[]): void { - ensureDir(); - const data: WhitelistFile = { version: 1, devices }; - writeFileSync(DEVICES_FILE, JSON.stringify(data, null, 2), "utf-8"); + const fallback = fallbackConversationId.trim(); + if (ids.size === 0 && fallback) { + ids.add(fallback); + } + return Array.from(ids); } // ============ DeviceStore ============ export class DeviceStore { + private readonly devicesDir: string; + private readonly devicesFile: string; /** One-time tokens (in-memory only, not persisted) */ private readonly tokens = new Map(); /** Allowed device IDs (persisted to disk) */ private readonly allowedDevices = new Map(); - constructor() { + constructor(options?: DeviceStoreOptions) { + this.devicesFile = options?.devicesFile ?? DEVICES_FILE; + this.devicesDir = options?.devicesFile ? dirname(options.devicesFile) : DEVICES_DIR; // Restore from persistent storage - for (const entry of loadDevices()) { + for (const entry of this.loadDevices()) { this.allowedDevices.set(entry.deviceId, entry); } } @@ -76,38 +81,75 @@ export class DeviceStore { // ---- Token management ---- /** Register a one-time token (called when QR code is generated) */ - registerToken(token: string, agentId: string, expiresAt: number): void { + registerToken(token: string, agentId: string, conversationId: string, expiresAt: number): void { // Clean up expired tokens to prevent accumulation const now = Date.now(); for (const [key, entry] of this.tokens) { if (now > entry.expiresAt) this.tokens.delete(key); } - this.tokens.set(token, { token, agentId, expiresAt }); + this.tokens.set(token, { token, agentId, conversationId, expiresAt }); } /** Validate and consume a token (one-time use). Returns agentId if valid, null otherwise. */ - consumeToken(token: string): { agentId: string } | null { + consumeToken(token: string): { agentId: string; conversationId: string } | null { const entry = this.tokens.get(token); if (!entry) return null; // Always delete — consumed or expired this.tokens.delete(token); if (Date.now() > entry.expiresAt) return null; - return { agentId: entry.agentId }; + return { agentId: entry.agentId, conversationId: entry.conversationId }; } // ---- Device whitelist ---- /** Add a device to the whitelist (called after token verification + user confirmation) */ - allowDevice(deviceId: string, agentId: string, meta?: DeviceMeta): void { - const entry: DeviceEntry = { deviceId, agentId, addedAt: Date.now(), meta }; + allowDevice(deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta): void { + const existing = this.allowedDevices.get(deviceId); + const conversationIds = existing && existing.agentId === agentId + ? normalizeConversationIds(existing.conversationIds, conversationId) + : normalizeConversationIds([], conversationId); + if (!conversationIds.includes(conversationId)) { + conversationIds.push(conversationId); + } + + const entry: DeviceEntry = { + deviceId, + agentId, + conversationIds, + addedAt: existing?.addedAt ?? Date.now(), + meta, + }; this.allowedDevices.set(deviceId, entry); this.persist(); } /** Check if a device is in the whitelist */ - isAllowed(deviceId: string): { agentId: string } | null { + isAllowed( + deviceId: string, + conversationId?: string, + ): { agentId: string; conversationIds: string[] } | null { const entry = this.allowedDevices.get(deviceId); - return entry ? { agentId: entry.agentId } : null; + if (!entry) return null; + + if (conversationId !== undefined && !entry.conversationIds.includes(conversationId)) { + return null; + } + + return { + agentId: entry.agentId, + conversationIds: [...entry.conversationIds], + }; + } + + /** Grant an additional conversation scope to an existing device. */ + allowConversation(deviceId: string, conversationId: string): boolean { + const entry = this.allowedDevices.get(deviceId); + if (!entry) return false; + if (entry.conversationIds.includes(conversationId)) return true; + entry.conversationIds.push(conversationId); + this.allowedDevices.set(deviceId, entry); + this.persist(); + return true; } /** Remove a device from the whitelist */ @@ -123,6 +165,56 @@ export class DeviceStore { } private persist(): void { - saveDevices(Array.from(this.allowedDevices.values())); + this.saveDevices(Array.from(this.allowedDevices.values())); + } + + private ensureDir(): void { + if (!existsSync(this.devicesDir)) { + mkdirSync(this.devicesDir, { recursive: true }); + } + } + + private loadDevices(): DeviceEntry[] { + if (!existsSync(this.devicesFile)) return []; + try { + const raw = JSON.parse(readFileSync(this.devicesFile, "utf-8")); + const devices = Array.isArray(raw) ? raw : (raw as WhitelistFile).devices ?? []; + if (!Array.isArray(devices)) return []; + + const normalized: DeviceEntry[] = []; + for (const item of devices) { + if (!item || typeof item !== "object") continue; + const rawDeviceId = (item as { deviceId?: unknown }).deviceId; + const rawAgentId = (item as { agentId?: unknown }).agentId; + if (typeof rawDeviceId !== "string" || typeof rawAgentId !== "string") continue; + const deviceId = rawDeviceId.trim(); + const agentId = rawAgentId.trim(); + if (!deviceId || !agentId) continue; + const fallbackConversationId = typeof (item as { conversationId?: unknown }).conversationId === "string" + ? (item as { conversationId: string }).conversationId + : agentId; + normalized.push({ + deviceId, + agentId, + conversationIds: normalizeConversationIds( + (item as { conversationIds?: unknown }).conversationIds, + fallbackConversationId, + ), + addedAt: typeof (item as { addedAt?: unknown }).addedAt === "number" + ? (item as { addedAt: number }).addedAt + : Date.now(), + meta: (item as { meta?: DeviceMeta }).meta, + }); + } + return normalized; + } catch { + return []; + } + } + + private saveDevices(devices: DeviceEntry[]): void { + this.ensureDir(); + const data: WhitelistFile = { version: 2, devices }; + writeFileSync(this.devicesFile, JSON.stringify(data, null, 2), "utf-8"); } } diff --git a/packages/core/src/hub/exec-approval-manager.test.ts b/packages/core/src/hub/exec-approval-manager.test.ts index fa38c902..1a0ae0e0 100644 --- a/packages/core/src/hub/exec-approval-manager.test.ts +++ b/packages/core/src/hub/exec-approval-manager.test.ts @@ -18,6 +18,7 @@ describe("ExecApprovalManager", () => { it("sends approval request to client and resolves on decision", async () => { const promise = manager.requestApproval({ agentId: "agent-1", + conversationId: "agent-1", command: "rm -rf /tmp/test", cwd: "/workspace", riskLevel: "dangerous", @@ -43,6 +44,7 @@ describe("ExecApprovalManager", () => { it("resolves with deny when decision is deny", async () => { const promise = manager.requestApproval({ agentId: "agent-1", + conversationId: "agent-1", command: "sudo reboot", riskLevel: "dangerous", riskReasons: [], @@ -59,6 +61,7 @@ describe("ExecApprovalManager", () => { it("resolves with allow-always", async () => { const promise = manager.requestApproval({ agentId: "agent-1", + conversationId: "agent-1", command: "git push", riskLevel: "needs-review", riskReasons: [], @@ -75,6 +78,7 @@ describe("ExecApprovalManager", () => { it("auto-denies on timeout (fail-closed)", async () => { const promise = manager.requestApproval({ agentId: "agent-1", + conversationId: "agent-1", command: "dangerous-command", riskLevel: "dangerous", riskReasons: [], @@ -91,6 +95,7 @@ describe("ExecApprovalManager", () => { it("keeps approval pending indefinitely when timeoutMs is -1", async () => { const promise = manager.requestApproval({ agentId: "agent-1", + conversationId: "agent-1", command: "cmd", riskLevel: "needs-review", riskReasons: [], @@ -112,6 +117,7 @@ describe("ExecApprovalManager", () => { it("honors askFallback full on timeout", async () => { const promise = manager.requestApproval({ agentId: "agent-1", + conversationId: "agent-1", command: "cmd", riskLevel: "needs-review", riskReasons: [], @@ -128,6 +134,7 @@ describe("ExecApprovalManager", () => { it("honors askFallback allowlist on timeout", async () => { const allowPromise = manager.requestApproval({ agentId: "agent-1", + conversationId: "agent-1", command: "cmd", riskLevel: "needs-review", riskReasons: [], @@ -143,6 +150,7 @@ describe("ExecApprovalManager", () => { const denyPromise = manager.requestApproval({ agentId: "agent-1", + conversationId: "agent-1", command: "cmd", riskLevel: "needs-review", riskReasons: [], @@ -165,6 +173,7 @@ describe("ExecApprovalManager", () => { it("returns false when resolving already-resolved approval", async () => { const promise = manager.requestApproval({ agentId: "agent-1", + conversationId: "agent-1", command: "cmd", riskLevel: "needs-review", riskReasons: [], @@ -183,6 +192,7 @@ describe("ExecApprovalManager", () => { it("cancels all pending approvals for an agent", async () => { const promise1 = manager.requestApproval({ agentId: "agent-1", + conversationId: "agent-1", command: "cmd1", riskLevel: "needs-review", riskReasons: [], @@ -190,6 +200,7 @@ describe("ExecApprovalManager", () => { const promise2 = manager.requestApproval({ agentId: "agent-1", + conversationId: "agent-1", command: "cmd2", riskLevel: "needs-review", riskReasons: [], @@ -197,6 +208,7 @@ describe("ExecApprovalManager", () => { const promise3 = manager.requestApproval({ agentId: "agent-2", + conversationId: "agent-2", command: "cmd3", riskLevel: "needs-review", riskReasons: [], @@ -231,6 +243,7 @@ describe("ExecApprovalManager", () => { const result = await failManager.requestApproval({ agentId: "agent-1", + conversationId: "agent-1", command: "cmd", riskLevel: "needs-review", riskReasons: [], @@ -243,6 +256,7 @@ describe("ExecApprovalManager", () => { it("getSnapshot returns request details", () => { manager.requestApproval({ agentId: "agent-1", + conversationId: "agent-1", command: "ls", riskLevel: "safe", riskReasons: [], @@ -265,6 +279,7 @@ describe("ExecApprovalManager", () => { manager.requestApproval({ agentId: "agent-1", + conversationId: "agent-1", command: "cmd1", riskLevel: "needs-review", riskReasons: [], @@ -273,6 +288,7 @@ describe("ExecApprovalManager", () => { manager.requestApproval({ agentId: "agent-1", + conversationId: "agent-1", command: "cmd2", riskLevel: "needs-review", riskReasons: [], diff --git a/packages/core/src/hub/exec-approval-manager.ts b/packages/core/src/hub/exec-approval-manager.ts index e3028b9a..eda4d1b5 100644 --- a/packages/core/src/hub/exec-approval-manager.ts +++ b/packages/core/src/hub/exec-approval-manager.ts @@ -24,7 +24,7 @@ interface PendingEntry { * The Hub wires this to Gateway message sending. */ export type SendApprovalToClient = ( - agentId: string, + conversationId: string, payload: ExecApprovalRequest, ) => void; @@ -42,6 +42,7 @@ export class ExecApprovalManager { */ requestApproval(params: { agentId: string; + conversationId: string; command: string; cwd?: string; riskLevel: "safe" | "needs-review" | "dangerous"; @@ -53,10 +54,12 @@ export class ExecApprovalManager { const approvalId = uuidv7(); const timeoutMs = params.timeoutMs ?? this.defaultTimeoutMs; const expiresAtMs = timeoutMs >= 0 ? Date.now() + timeoutMs : -1; + const conversationId = params.conversationId; const request: ExecApprovalRequest = { approvalId, agentId: params.agentId, + conversationId, command: params.command, cwd: params.cwd, riskLevel: params.riskLevel, @@ -85,7 +88,7 @@ export class ExecApprovalManager { // Send to client via Gateway try { - this.sendToClient(params.agentId, request); + this.sendToClient(conversationId, request); } catch (err) { // If sending fails, auto-deny (fail-closed) if (timer) clearTimeout(timer); @@ -121,7 +124,10 @@ export class ExecApprovalManager { */ cancelPending(agentId: string): void { for (const [id, entry] of this.pending) { - if (entry.request.agentId === agentId) { + if ( + entry.request.agentId === agentId + || entry.request.conversationId === agentId + ) { if (entry.timer) clearTimeout(entry.timer); this.pending.delete(id); entry.resolve({ approved: false, decision: "deny" }); diff --git a/packages/core/src/hub/hub.ts b/packages/core/src/hub/hub.ts index bafa2553..fdeb5356 100644 --- a/packages/core/src/hub/hub.ts +++ b/packages/core/src/hub/hub.ts @@ -17,13 +17,19 @@ import { AsyncAgent } from "../agent/async-agent.js"; import type { AgentOptions } from "../agent/types.js"; import { getHubId } from "./hub-identity.js"; import { setHub } from "./hub-singleton.js"; -import { loadAgentRecords, addAgentRecord, removeAgentRecord } from "./agent-store.js"; +import { + loadHubStoreSnapshot, + upsertAgentRecord, + upsertConversationRecord, + removeConversationRecordById, + removeAgentRecordById, +} from "./agent-store.js"; import { RpcDispatcher, RpcError } from "./rpc/dispatcher.js"; import { createGetAgentMessagesHandler } from "./rpc/handlers/get-agent-messages.js"; import { createGetHubInfoHandler } from "./rpc/handlers/get-hub-info.js"; -import { createListAgentsHandler } from "./rpc/handlers/list-agents.js"; -import { createCreateAgentHandler } from "./rpc/handlers/create-agent.js"; -import { createDeleteAgentHandler } from "./rpc/handlers/delete-agent.js"; +import { createListConversationsHandler } from "./rpc/handlers/list-conversations.js"; +import { createCreateConversationHandler } from "./rpc/handlers/create-conversation.js"; +import { createDeleteConversationHandler } from "./rpc/handlers/delete-conversation.js"; import { createUpdateGatewayHandler } from "./rpc/handlers/update-gateway.js"; import { createGetLastHeartbeatHandler } from "./rpc/handlers/get-last-heartbeat.js"; import { createSetHeartbeatsHandler } from "./rpc/handlers/set-heartbeats.js"; @@ -64,17 +70,26 @@ export type MessageSource = /** Inbound message event broadcast to all listeners */ export interface InboundMessageEvent { agentId: string; + /** Conversation ID for this inbound message. */ + conversationId: string; content: string; source: MessageSource; timestamp: number; } export class Hub { + // Runtime conversation map (conversationId -> AsyncAgent). private readonly agents = new Map(); + // Conversation ownership map (conversationId -> logical agentId). + private readonly conversationAgents = new Map(); + // Main conversation pointer for each agent (agentId -> mainConversationId). + private readonly agentMainConversations = new Map(); + // Runtime profile for each logical agent. + private readonly agentProfiles = new Map(); private readonly agentSenders = new Map(); private readonly agentStreamIds = new Map(); private readonly agentStreamCounters = new Map(); - private readonly pendingAssistantStarts = new Map(); + private readonly pendingAssistantStarts = new Map(); private readonly suppressedStreamAgents = new Set(); private readonly localApprovalHandlers = new Map void>(); private readonly inboundListeners = new Set<(event: InboundMessageEvent) => void>(); @@ -85,7 +100,9 @@ export class Hub { private heartbeatUnsubscribe: (() => void) | null = null; private client: GatewayClient; readonly deviceStore: DeviceStore; - private _onConfirmDevice: ((deviceId: string, agentId: string, meta?: DeviceMeta) => Promise) | null = null; + private _onConfirmDevice: ( + (deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta) => Promise + ) | null = null; private _stateChangeListeners: ((state: ConnectionState) => void)[] = []; readonly channelManager: ChannelManager; url: string; @@ -107,37 +124,45 @@ export class Hub { this.rpc.register("verify", createVerifyHandler({ hubId: this.hubId, deviceStore: this.deviceStore, - onConfirmDevice: (deviceId, agentId, meta) => { + resolveMainConversationId: (agentId) => this.getAgentMainConversationId(agentId), + onConfirmDevice: (deviceId, agentId, conversationId, meta) => { if (!this._onConfirmDevice) { // No UI confirm handler registered (CLI mode etc.) — auto-approve return Promise.resolve(true); } - return this._onConfirmDevice(deviceId, agentId, meta); + return this._onConfirmDevice(deviceId, agentId, conversationId, meta); }, })); this.rpc.register("generateChannelWelcome", createGenerateChannelWelcomeHandler(this)); - this.rpc.register("getAgentMessages", createGetAgentMessagesHandler()); + this.rpc.register("getAgentMessages", createGetAgentMessagesHandler((agentId, conversationId) => { + const resolvedConversationId = this.normalizeId(conversationId); + if (!resolvedConversationId) return null; + return { + conversationId: resolvedConversationId, + storageAgentId: this.getConversationAgentId(resolvedConversationId) ?? this.normalizeId(agentId), + }; + })); this.rpc.register("getHubInfo", createGetHubInfoHandler(this)); - this.rpc.register("listAgents", createListAgentsHandler(this)); - this.rpc.register("createAgent", createCreateAgentHandler(this)); - this.rpc.register("deleteAgent", createDeleteAgentHandler(this)); + this.rpc.register("listConversations", createListConversationsHandler(this)); + this.rpc.register("createConversation", createCreateConversationHandler(this)); + this.rpc.register("deleteConversation", createDeleteConversationHandler(this)); this.rpc.register("updateGateway", createUpdateGatewayHandler(this)); this.rpc.register("last-heartbeat", createGetLastHeartbeatHandler(this)); this.rpc.register("set-heartbeats", createSetHeartbeatsHandler(this)); this.rpc.register("wake-heartbeat", createWakeHeartbeatHandler(this)); // Initialize exec approval manager - this.approvalManager = new ExecApprovalManager((agentId, payload) => { + this.approvalManager = new ExecApprovalManager((conversationId, payload) => { // Check local IPC handler first (for desktop direct chat) - const localHandler = this.localApprovalHandlers.get(agentId); + const localHandler = this.localApprovalHandlers.get(conversationId); if (localHandler) { localHandler(payload); return; } // Remote: send via Gateway - const targetDeviceId = this.agentSenders.get(agentId); + const targetDeviceId = this.agentSenders.get(conversationId); if (!targetDeviceId) { - throw new Error(`No client device found for agent ${agentId}`); + throw new Error(`No client device found for conversation ${conversationId}`); } this.client.send(targetDeviceId, "exec-approval-request", payload); }); @@ -199,20 +224,144 @@ export class Hub { } private getDefaultAgent(): AsyncAgent | null { - const first = this.listAgents()[0]; - if (!first) return null; - return this.getAgent(first) ?? null; + const firstConversationId = this.listConversations()[0]; + if (!firstConversationId) return null; + return this.getConversation(firstConversationId) ?? null; } /** Restore agents from persistent storage */ private restoreAgents(): void { - const records = loadAgentRecords(); - for (const record of records) { - this.createAgent(record.id, { persist: false }); + const snapshot = loadHubStoreSnapshot(); + + for (const agent of snapshot.agents) { + this.agentProfiles.set(agent.id, agent.profileId ?? "default"); } - if (records.length > 0) { - console.log(`[Hub] Restored ${records.length} agent(s)`); + + for (const conversation of snapshot.conversations) { + this.createConversation(conversation.id, { + agentId: conversation.agentId, + profileId: conversation.profileId ?? this.agentProfiles.get(conversation.agentId) ?? "default", + persist: false, + createdAt: conversation.createdAt, + isMainConversation: !this.agentMainConversations.has(conversation.agentId), + }); } + + if (snapshot.conversations.length > 0) { + console.log( + `[Hub] Restored ${snapshot.agents.length} agent(s), ${snapshot.conversations.length} conversation(s)`, + ); + } + } + + private normalizeId(value: string | undefined): string | undefined { + const normalized = (value ?? "").trim(); + return normalized || undefined; + } + + private listConversationIdsForAgent(agentId: string): string[] { + const ids: string[] = []; + for (const [conversationId, ownerAgentId] of this.conversationAgents.entries()) { + const runtime = this.agents.get(conversationId); + if (ownerAgentId === agentId && runtime && !runtime.closed) { + ids.push(conversationId); + } + } + return ids; + } + + private resolveAgentMainConversationId(agentId: string): string | undefined { + const main = this.agentMainConversations.get(agentId); + if (main) { + const runtime = this.agents.get(main); + if (runtime && !runtime.closed) { + return main; + } + } + + const fallback = this.listConversationIdsForAgent(agentId)[0]; + if (!fallback) return undefined; + this.agentMainConversations.set(agentId, fallback); + return fallback; + } + + private resolveAgentId(agentId: string | undefined, conversationId: string): string { + const explicitAgentId = this.normalizeId(agentId); + if (explicitAgentId && this.agentMainConversations.has(explicitAgentId)) { + return explicitAgentId; + } + if (explicitAgentId && this.conversationAgents.has(explicitAgentId)) { + return this.conversationAgents.get(explicitAgentId) ?? explicitAgentId; + } + const owner = this.conversationAgents.get(conversationId); + if (owner) return owner; + return explicitAgentId ?? conversationId; + } + + private resolveTargetAgentId(agentId: string | undefined, fallbackConversationId: string): string { + const normalized = this.normalizeId(agentId); + if (normalized) return normalized; + const firstAgentId = this.listAgents()[0]; + return firstAgentId ?? fallbackConversationId; + } + + private registerAgent( + agentId: string, + options: { profileId: string; createdAt: number; persist: boolean }, + ): void { + const exists = this.agentProfiles.has(agentId); + if (exists) { + const currentProfileId = this.agentProfiles.get(agentId); + if (currentProfileId !== options.profileId) { + this.agentProfiles.set(agentId, options.profileId); + } + return; + } + + this.agentProfiles.set(agentId, options.profileId); + if (options.persist) { + upsertAgentRecord({ + id: agentId, + createdAt: options.createdAt, + profileId: options.profileId, + }); + } + } + + private clearAgentIfNoConversation(agentId: string): void { + const remaining = this.listConversationIdsForAgent(agentId); + if (remaining.length > 0) { + if (!this.agentMainConversations.get(agentId)) { + this.agentMainConversations.set(agentId, remaining[0]!); + } + return; + } + this.agentMainConversations.delete(agentId); + this.agentProfiles.delete(agentId); + removeAgentRecordById(agentId); + } + + private closeConversationRuntime(conversationId: string, options?: { persist?: boolean }): { ok: boolean; agentId?: string } { + const runtime = this.agents.get(conversationId); + if (!runtime) return { ok: false }; + + const agentId = this.conversationAgents.get(conversationId) ?? conversationId; + runtime.close(); + this.approvalManager.cancelPending(conversationId); + this.agents.delete(conversationId); + this.conversationAgents.delete(conversationId); + this.agentSenders.delete(conversationId); + this.agentStreamIds.delete(conversationId); + this.agentStreamCounters.delete(conversationId); + this.clearPendingAssistantStarts(conversationId); + this.suppressedStreamAgents.delete(conversationId); + this.localApprovalHandlers.delete(conversationId); + + if (options?.persist !== false) { + removeConversationRecordById(conversationId); + } + + return { ok: true, agentId }; } private createClient(url: string): GatewayClient { @@ -265,38 +414,79 @@ export class Hub { } // Non-RPC messages also require verified device + const payload = msg.payload as { + agentId?: string; + conversationId?: string; + content?: string; + } | undefined; if (!this.deviceStore.isAllowed(msg.from)) { console.warn(`[Hub] Rejected message from unverified device: ${msg.from}`); this.client.send(msg.from, "error", { code: "UNAUTHORIZED", message: "Device not verified. Please complete verification first.", messageId: msg.id, + ...(payload?.conversationId ? { conversationId: payload.conversationId } : {}), + }); + return; + } + const inboundConversationId = this.normalizeId(payload?.conversationId); + if (!inboundConversationId) { + this.client.send(msg.from, "error", { + code: "INVALID_PARAMS", + message: "Missing required conversationId.", + messageId: msg.id, + }); + return; + } + const incomingAgentId = payload?.agentId; + const conversationId = inboundConversationId; + const agentId = this.resolveAgentId(incomingAgentId, conversationId); + const content = payload?.content; + if (!content) { + console.warn("[Hub] Invalid payload, missing content"); + return; + } + + const allowedScope = this.deviceStore.isAllowed(msg.from, conversationId); + if (!allowedScope) { + console.warn(`[Hub] Rejected message outside authorized conversation scope: ${msg.from} -> ${conversationId}`); + this.client.send(msg.from, "error", { + code: "UNAUTHORIZED", + message: "Device is not authorized for this conversation.", + messageId: msg.id, + conversationId, }); return; } - // Regular chat message - const payload = msg.payload as { agentId?: string; content?: string } | undefined; - const agentId = payload?.agentId; - const content = payload?.content; - if (!agentId || !content) { - console.warn(`[Hub] Invalid payload, missing agentId or content`); + if (allowedScope.agentId !== agentId) { + console.warn( + `[Hub] Rejected message due to agent mismatch: device=${msg.from}, allowedAgent=${allowedScope.agentId}, targetAgent=${agentId}`, + ); + this.client.send(msg.from, "error", { + code: "UNAUTHORIZED", + message: "Device is not authorized for this agent.", + messageId: msg.id, + conversationId, + }); return; } - const agent = this.agents.get(agentId); + + const agent = this.agents.get(conversationId); if (agent && !agent.closed) { - this.agentSenders.set(agentId, msg.from); + this.agentSenders.set(conversationId, msg.from); this.channelManager.clearLastRoute(); const source: MessageSource = { type: "gateway", deviceId: msg.from }; this.broadcastInbound({ agentId, + conversationId, content, source, timestamp: Date.now(), }); agent.write(content, { source }); } else { - console.warn(`[Hub] Agent not found or closed: ${agentId}`); + console.warn(`[Hub] Conversation not found or closed: ${conversationId} (agent=${incomingAgentId})`); } }); @@ -308,7 +498,11 @@ export class Hub { } /** Register a confirmation handler for new device connections (called by Desktop UI) */ - setConfirmHandler(handler: ((deviceId: string, agentId: string, meta?: DeviceMeta) => Promise) | null): void { + setConfirmHandler( + handler: ( + (deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta) => Promise + ) | null, + ): void { this._onConfirmDevice = handler; } @@ -337,8 +531,20 @@ export class Hub { } /** Register a one-time token for device verification (called when QR code is generated) */ - registerToken(token: string, agentId: string, expiresAt: number): void { - this.deviceStore.registerToken(token, agentId, expiresAt); + registerToken(token: string, agentId: string, conversationId: string, expiresAt: number): void { + const normalizedAgentId = this.normalizeId(agentId); + const normalizedConversationId = this.normalizeId(conversationId); + if (!normalizedAgentId || !normalizedConversationId) return; + + const ownerAgentId = this.conversationAgents.get(normalizedConversationId); + if (ownerAgentId && ownerAgentId !== normalizedAgentId) { + console.warn( + `[Hub] registerToken rejected due to agent/conversation mismatch: agent=${normalizedAgentId}, conversation=${normalizedConversationId}, owner=${ownerAgentId}`, + ); + return; + } + const resolvedAgentId = ownerAgentId ?? normalizedAgentId; + this.deviceStore.registerToken(token, resolvedAgentId, normalizedConversationId, expiresAt); } /** 重连到新的 Gateway 地址 */ @@ -365,33 +571,96 @@ export class Hub { return this.approvalManager.resolveApproval(approvalId, decision); } - /** Create new Agent, or rebuild with existing ID */ - createAgent(id?: string, options?: { persist?: boolean; profileId?: string }): AsyncAgent { - if (id) { - const existing = this.agents.get(id); + /** Create a logical agent and its main conversation runtime. */ + createAgent( + id?: string, + options?: { persist?: boolean; profileId?: string; mainConversationId?: string; createdAt?: number }, + ): AsyncAgent { + const agentId = this.normalizeId(id) ?? uuidv7(); + const existingMainConversationId = this.resolveAgentMainConversationId(agentId); + if (existingMainConversationId) { + const existing = this.agents.get(existingMainConversationId); if (existing && !existing.closed) { return existing; } } - const profileId = options?.profileId ?? "default"; - const sessionId = id ?? uuidv7(); - const onExecApprovalNeeded = this.createExecApprovalCallback(sessionId, profileId); - const onChannelSendFile = this.createChannelSendFileCallback(sessionId); - const channels = this.channelManager.listChannelInfos(); - const agent = new AsyncAgent({ sessionId, profileId, onExecApprovalNeeded, onChannelSendFile, channels }); - this.agents.set(agent.sessionId, agent); + const mainConversationId = this.normalizeId(options?.mainConversationId) ?? agentId; + return this.createConversation(mainConversationId, { + agentId, + profileId: options?.profileId, + persist: options?.persist, + isMainConversation: true, + createdAt: options?.createdAt, + }); + } - // Persist to agent store (skip during restore to avoid duplicates) - if (options?.persist !== false) { - addAgentRecord({ id: agent.sessionId, createdAt: Date.now() }); + /** + * Create a new conversation runtime. + * + * Semantics: + * - Agent = long-lived capability/profile identity + * - Conversation = isolated runtime/session thread + */ + createConversation( + id?: string, + options?: { + persist?: boolean; + profileId?: string; + agentId?: string; + isMainConversation?: boolean; + createdAt?: number; + }, + ): AsyncAgent { + const conversationId = this.normalizeId(id) ?? uuidv7(); + const existing = this.agents.get(conversationId); + if (existing && !existing.closed) { + return existing; + } + + const targetAgentId = this.resolveTargetAgentId(options?.agentId, conversationId); + const profileId = options?.profileId ?? this.agentProfiles.get(targetAgentId) ?? "default"; + const createdAt = options?.createdAt ?? Date.now(); + const persist = options?.persist !== false; + + this.registerAgent(targetAgentId, { + profileId, + createdAt, + persist, + }); + + const onExecApprovalNeeded = this.createExecApprovalCallback(conversationId, targetAgentId, profileId); + const onChannelSendFile = this.createChannelSendFileCallback(conversationId); + const channels = this.channelManager.listChannelInfos(); + const agent = new AsyncAgent({ + sessionId: conversationId, + ownerAgentId: targetAgentId, + profileId, + onExecApprovalNeeded, + onChannelSendFile, + channels, + }); + + this.agents.set(conversationId, agent); + this.conversationAgents.set(conversationId, targetAgentId); + if (options?.isMainConversation || !this.agentMainConversations.has(targetAgentId)) { + this.agentMainConversations.set(targetAgentId, conversationId); + } + + if (persist) { + upsertConversationRecord({ + id: conversationId, + agentId: targetAgentId, + createdAt, + profileId, + }); } // Internally consume agent output (AgentEvent stream + error Messages) void this.consumeAgent(agent); this.heartbeatRunner?.updateConfig(); - console.log(`Agent created: ${agent.sessionId}`); + console.log(`[Hub] Conversation created: ${conversationId} (agent: ${targetAgentId})`); return agent; } @@ -426,7 +695,7 @@ export class Hub { private clearPendingAssistantStarts(agentId: string): void { for (const [streamId, pending] of this.pendingAssistantStarts) { - if (pending.agentId === agentId) { + if (pending.agentId === agentId || pending.conversationId === agentId) { this.pendingAssistantStarts.delete(streamId); } } @@ -434,28 +703,31 @@ export class Hub { /** Internally read agent output and send via Gateway */ private async consumeAgent(agent: AsyncAgent): Promise { + const conversationId = agent.sessionId; + const agentId = this.conversationAgents.get(conversationId) ?? conversationId; for await (const item of agent.read()) { - const targetDeviceId = this.agentSenders.get(agent.sessionId); + const targetDeviceId = this.agentSenders.get(conversationId); if (!targetDeviceId) continue; if ("content" in item) { // Legacy Message (error fallback) - console.log(`[${agent.sessionId}] ${item.content}`); + console.log(`[${conversationId}] ${item.content}`); this.client.send(targetDeviceId, "message", { - agentId: agent.sessionId, + agentId, + conversationId, content: item.content, }); } else { - const suppressForAgent = this.suppressedStreamAgents.has(agent.sessionId); + const suppressForAgent = this.suppressedStreamAgents.has(conversationId); // Suppress all user-visible stream events during silent heartbeat runs. if (suppressForAgent) { if (item.type === "message_start") { - this.beginStream(agent.sessionId, item); + this.beginStream(conversationId, item); } else if (item.type === "message_end") { - const streamId = this.getActiveStreamId(agent.sessionId, item); + const streamId = this.getActiveStreamId(conversationId, item); this.pendingAssistantStarts.delete(streamId); - this.endStream(agent.sessionId); + this.endStream(conversationId); } continue; } @@ -465,8 +737,9 @@ export class Hub { item.type === "compaction_start" || item.type === "compaction_end" || item.type === "agent_error"; if (isPassthroughEvent) { this.client.send(targetDeviceId, StreamAction, { - streamId: `system:${agent.sessionId}`, - agentId: agent.sessionId, + streamId: `system:${conversationId}`, + agentId, + conversationId, event: item, }); continue; @@ -489,17 +762,17 @@ export class Hub { // This lets us suppress pure HEARTBEAT_OK acknowledgements end-to-end. if (isAssistantMessageEvent && isAssistantMessage) { if (item.type === "message_start") { - const streamId = this.beginStream(agent.sessionId, item); - this.pendingAssistantStarts.set(streamId, { agentId: agent.sessionId, event: item }); + const streamId = this.beginStream(conversationId, item); + this.pendingAssistantStarts.set(streamId, { agentId, conversationId, event: item }); continue; } - const streamId = this.getActiveStreamId(agent.sessionId, item); + const streamId = this.getActiveStreamId(conversationId, item); const isHeartbeatAck = isHeartbeatAckEvent(item); if (isHeartbeatAck) { if (item.type === "message_end") { this.pendingAssistantStarts.delete(streamId); - this.endStream(agent.sessionId); + this.endStream(conversationId); } continue; } @@ -508,7 +781,8 @@ export class Hub { if (pendingStart) { this.client.send(targetDeviceId, StreamAction, { streamId, - agentId: agent.sessionId, + agentId: pendingStart.agentId, + conversationId: pendingStart.conversationId, event: pendingStart.event, }); this.pendingAssistantStarts.delete(streamId); @@ -516,19 +790,21 @@ export class Hub { this.client.send(targetDeviceId, StreamAction, { streamId, - agentId: agent.sessionId, + agentId, + conversationId, event: item, }); if (item.type === "message_end") { - this.endStream(agent.sessionId); + this.endStream(conversationId); } continue; } - const streamId = this.getActiveStreamId(agent.sessionId, item); + const streamId = this.getActiveStreamId(conversationId, item); this.client.send(targetDeviceId, StreamAction, { streamId, - agentId: agent.sessionId, + agentId, + conversationId, event: item, }); } @@ -540,6 +816,12 @@ export class Hub { const { requestId, method } = request; try { const result = await this.rpc.dispatch(method, request.params, from); + if (method === "createConversation") { + const createdConversationId = (result as { id?: unknown }).id; + if (typeof createdConversationId === "string" && createdConversationId) { + this.deviceStore.allowConversation(from, createdConversationId); + } + } this.client.send(from, ResponseAction, { requestId, ok: true, @@ -582,7 +864,7 @@ export class Hub { * Create an exec approval callback for an agent. * This wires the safety evaluation + Hub approval manager together. */ - private createExecApprovalCallback(sessionId: string, profileId: string): ExecApprovalCallback { + private createExecApprovalCallback(conversationId: string, agentId: string, profileId: string): ExecApprovalCallback { return async (command: string, cwd: string | undefined): Promise => { // Load exec approval config from profile let config: ExecApprovalConfig = {}; @@ -636,7 +918,8 @@ export class Hub { // Request approval via Hub → Gateway → Client const result = await this.approvalManager.requestApproval({ - agentId: sessionId, + agentId, + conversationId, command, ...(cwd !== undefined ? { cwd } : {}), riskLevel: evaluation.riskLevel, @@ -688,6 +971,7 @@ export class Hub { type, caption, filename: basename(filePath), + conversationId: sessionId, }); console.log(`[Hub] Sent file via gateway: ${basename(filePath)} → ${deviceId}`); return true; @@ -702,13 +986,57 @@ export class Hub { } getAgent(id: string): AsyncAgent | undefined { - return this.agents.get(id); + const normalizedId = this.normalizeId(id); + if (!normalizedId) return undefined; + + const directConversation = this.agents.get(normalizedId); + if (directConversation && !directConversation.closed) { + return directConversation; + } + + const mainConversationId = this.resolveAgentMainConversationId(normalizedId); + if (!mainConversationId) return undefined; + const mainConversation = this.agents.get(mainConversationId); + if (!mainConversation || mainConversation.closed) return undefined; + return mainConversation; + } + + getConversation(id: string): AsyncAgent | undefined { + const normalizedId = this.normalizeId(id); + if (!normalizedId) return undefined; + const conversation = this.agents.get(normalizedId); + if (!conversation || conversation.closed) return undefined; + return conversation; + } + + getConversationAgentId(conversationId: string): string | undefined { + const normalizedConversationId = this.normalizeId(conversationId); + if (!normalizedConversationId) return undefined; + return this.conversationAgents.get(normalizedConversationId); + } + + getAgentMainConversationId(agentId: string): string | undefined { + const normalizedAgentId = this.normalizeId(agentId); + if (!normalizedAgentId) return undefined; + return this.resolveAgentMainConversationId(normalizedAgentId); } listAgents(): string[] { + const activeAgentIds = new Set(); + for (const [conversationId, runtime] of this.agents.entries()) { + if (runtime.closed) continue; + const agentId = this.conversationAgents.get(conversationId); + if (agentId) { + activeAgentIds.add(agentId); + } + } + return Array.from(activeAgentIds.values()); + } + + listConversations(): string[] { return Array.from(this.agents.entries()) - .filter(([, a]) => !a.closed) - .map(([id]) => id); + .filter(([conversationId, runtime]) => !runtime.closed && this.conversationAgents.has(conversationId)) + .map(([conversationId]) => conversationId); } /** Subscribe heartbeat state updates. Returns unsubscribe callback. */ @@ -763,24 +1091,61 @@ export class Hub { /** Enqueue a system event for a specific agent or the default agent. */ enqueueSystemEvent(text: string, opts?: { agentId?: string }): void { - const agentId = opts?.agentId ?? this.listAgents()[0]; - if (!agentId) return; - enqueueSystemEvent(text, { sessionKey: agentId }); + const requestedAgentId = this.normalizeId(opts?.agentId); + const conversationId = requestedAgentId + ? this.resolveAgentMainConversationId(requestedAgentId) + : this.listConversations()[0]; + if (!conversationId) return; + enqueueSystemEvent(text, { sessionKey: conversationId }); } closeAgent(id: string): boolean { - const agent = this.agents.get(id); - if (!agent) return false; - agent.close(); - this.approvalManager.cancelPending(id); - this.agents.delete(id); - this.agentSenders.delete(id); - this.agentStreamIds.delete(id); - this.agentStreamCounters.delete(id); - this.clearPendingAssistantStarts(id); - this.suppressedStreamAgents.delete(id); - this.localApprovalHandlers.delete(id); - removeAgentRecord(id); + const normalizedId = this.normalizeId(id); + if (!normalizedId) return false; + + const resolvedAgentId = this.agentMainConversations.has(normalizedId) + ? normalizedId + : this.conversationAgents.get(normalizedId) ?? normalizedId; + const conversationIds = this.listConversationIdsForAgent(resolvedAgentId); + if (conversationIds.length === 0) { + return this.closeConversation(normalizedId); + } + + let closedAny = false; + for (const conversationId of conversationIds) { + const closed = this.closeConversationRuntime(conversationId, { persist: false }); + closedAny = closedAny || closed.ok; + } + if (!closedAny) return false; + + this.agentMainConversations.delete(resolvedAgentId); + this.agentProfiles.delete(resolvedAgentId); + removeAgentRecordById(resolvedAgentId); + this.heartbeatRunner?.updateConfig(); + return closedAny; + } + + closeConversation(id: string): boolean { + const normalizedId = this.normalizeId(id); + if (!normalizedId) return false; + const conversationId = this.agents.has(normalizedId) + ? normalizedId + : this.resolveAgentMainConversationId(normalizedId); + if (!conversationId) return false; + + const { ok, agentId } = this.closeConversationRuntime(conversationId); + if (!ok || !agentId) return false; + + const currentMainConversationId = this.agentMainConversations.get(agentId); + if (currentMainConversationId === conversationId) { + this.agentMainConversations.delete(agentId); + const replacementConversationId = this.listConversationIdsForAgent(agentId)[0]; + if (replacementConversationId) { + this.agentMainConversations.set(agentId, replacementConversationId); + } + } + + this.clearAgentIfNoConversation(agentId); this.heartbeatRunner?.updateConfig(); return true; } @@ -797,16 +1162,19 @@ export class Hub { this.heartbeatUnsubscribe = null; this.heartbeatListeners.clear(); - for (const [id, agent] of this.agents) { + for (const [conversationId, agent] of this.agents) { agent.close(); - this.agents.delete(id); - this.agentSenders.delete(id); - this.agentStreamIds.delete(id); - this.agentStreamCounters.delete(id); - this.clearPendingAssistantStarts(id); - this.suppressedStreamAgents.delete(id); - this.localApprovalHandlers.delete(id); + this.agents.delete(conversationId); + this.conversationAgents.delete(conversationId); + this.agentSenders.delete(conversationId); + this.agentStreamIds.delete(conversationId); + this.agentStreamCounters.delete(conversationId); + this.clearPendingAssistantStarts(conversationId); + this.suppressedStreamAgents.delete(conversationId); + this.localApprovalHandlers.delete(conversationId); } + this.agentMainConversations.clear(); + this.agentProfiles.clear(); this.client.disconnect(); console.log("Hub shut down"); } diff --git a/packages/core/src/hub/rpc/handlers/create-agent.ts b/packages/core/src/hub/rpc/handlers/create-agent.ts deleted file mode 100644 index c0ab1186..00000000 --- a/packages/core/src/hub/rpc/handlers/create-agent.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { RpcHandler } from "../dispatcher.js"; - -interface HubLike { - createAgent(id?: string): { sessionId: string }; -} - -export function createCreateAgentHandler(hub: HubLike): RpcHandler { - return (params: unknown) => { - const { id } = (params ?? {}) as { id?: string }; - const agent = hub.createAgent(id); - return { id: agent.sessionId }; - }; -} diff --git a/packages/core/src/hub/rpc/handlers/create-conversation.test.ts b/packages/core/src/hub/rpc/handlers/create-conversation.test.ts new file mode 100644 index 00000000..81a86bc2 --- /dev/null +++ b/packages/core/src/hub/rpc/handlers/create-conversation.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect, vi } from "vitest"; +import { createCreateConversationHandler } from "./create-conversation.js"; + +describe("createCreateConversationHandler", () => { + it("creates conversation with explicit id and agent id", () => { + const createConversation = vi.fn(() => ({ sessionId: "conv-1" })); + const handler = createCreateConversationHandler({ createConversation }); + + const result = handler({ id: "custom-id", agentId: "agent-1" }, "device-1") as { id: string }; + + expect(createConversation).toHaveBeenCalledWith("custom-id", { agentId: "agent-1" }); + expect(result).toEqual({ id: "conv-1" }); + }); + + it("creates conversation without id when params are missing", () => { + const createConversation = vi.fn(() => ({ sessionId: "conv-2" })); + const handler = createCreateConversationHandler({ createConversation }); + + const result = handler(undefined, "device-1") as { id: string }; + + expect(createConversation).toHaveBeenCalledWith(undefined, { agentId: undefined }); + expect(result).toEqual({ id: "conv-2" }); + }); +}); diff --git a/packages/core/src/hub/rpc/handlers/create-conversation.ts b/packages/core/src/hub/rpc/handlers/create-conversation.ts new file mode 100644 index 00000000..6862aadd --- /dev/null +++ b/packages/core/src/hub/rpc/handlers/create-conversation.ts @@ -0,0 +1,13 @@ +import type { RpcHandler } from "../dispatcher.js"; + +interface HubLike { + createConversation(id?: string, options?: { agentId?: string }): { sessionId: string }; +} + +export function createCreateConversationHandler(hub: HubLike): RpcHandler { + return (params: unknown) => { + const { id, agentId } = (params ?? {}) as { id?: string; agentId?: string }; + const conversation = hub.createConversation(id, { agentId }); + return { id: conversation.sessionId }; + }; +} diff --git a/packages/core/src/hub/rpc/handlers/delete-conversation.test.ts b/packages/core/src/hub/rpc/handlers/delete-conversation.test.ts new file mode 100644 index 00000000..2b583d53 --- /dev/null +++ b/packages/core/src/hub/rpc/handlers/delete-conversation.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect, vi } from "vitest"; +import { RpcError } from "../dispatcher.js"; +import { createDeleteConversationHandler } from "./delete-conversation.js"; + +describe("createDeleteConversationHandler", () => { + it("throws INVALID_PARAMS when params are not an object", () => { + const closeConversation = vi.fn(); + const handler = createDeleteConversationHandler({ closeConversation }); + + expect(() => handler(undefined, "device-1")).toThrowError(RpcError); + expect(() => handler(undefined, "device-1")).toThrowError("params must be an object"); + expect(closeConversation).not.toHaveBeenCalled(); + }); + + it("throws INVALID_PARAMS when id is missing", () => { + const closeConversation = vi.fn(); + const handler = createDeleteConversationHandler({ closeConversation }); + + expect(() => handler({}, "device-1")).toThrowError(RpcError); + expect(() => handler({}, "device-1")).toThrowError("Missing required param: id"); + expect(closeConversation).not.toHaveBeenCalled(); + }); + + it("closes conversation when id is provided", () => { + const closeConversation = vi.fn(() => true); + const handler = createDeleteConversationHandler({ closeConversation }); + + const result = handler({ id: "conv-1" }, "device-1") as { ok: boolean }; + + expect(closeConversation).toHaveBeenCalledWith("conv-1"); + expect(result).toEqual({ ok: true }); + }); +}); diff --git a/packages/core/src/hub/rpc/handlers/delete-agent.ts b/packages/core/src/hub/rpc/handlers/delete-conversation.ts similarity index 72% rename from packages/core/src/hub/rpc/handlers/delete-agent.ts rename to packages/core/src/hub/rpc/handlers/delete-conversation.ts index 72207f24..ecc93f8d 100644 --- a/packages/core/src/hub/rpc/handlers/delete-agent.ts +++ b/packages/core/src/hub/rpc/handlers/delete-conversation.ts @@ -1,10 +1,10 @@ import { RpcError, type RpcHandler } from "../dispatcher.js"; interface HubLike { - closeAgent(id: string): boolean; + closeConversation(id: string): boolean; } -export function createDeleteAgentHandler(hub: HubLike): RpcHandler { +export function createDeleteConversationHandler(hub: HubLike): RpcHandler { return (params: unknown) => { if (!params || typeof params !== "object") { throw new RpcError("INVALID_PARAMS", "params must be an object"); @@ -13,7 +13,7 @@ export function createDeleteAgentHandler(hub: HubLike): RpcHandler { if (!id) { throw new RpcError("INVALID_PARAMS", "Missing required param: id"); } - const ok = hub.closeAgent(id); + const ok = hub.closeConversation(id); return { ok }; }; } diff --git a/packages/core/src/hub/rpc/handlers/get-agent-messages.ts b/packages/core/src/hub/rpc/handlers/get-agent-messages.ts index 3a008c70..d5255bfc 100644 --- a/packages/core/src/hub/rpc/handlers/get-agent-messages.ts +++ b/packages/core/src/hub/rpc/handlers/get-agent-messages.ts @@ -8,27 +8,54 @@ const DEFAULT_LIMIT = 200; interface GetAgentMessagesParams { agentId: string; + conversationId: string; offset?: number; limit?: number; } -export function createGetAgentMessagesHandler(): RpcHandler { +interface ResolvedConversation { + conversationId: string; + storageAgentId?: string; +} + +type ConversationResolver = (agentId: string, conversationId: string) => ResolvedConversation | null; + +export function createGetAgentMessagesHandler(resolveConversationId?: ConversationResolver): RpcHandler { return (params: unknown) => { if (!params || typeof params !== "object") { throw new RpcError("INVALID_PARAMS", "params must be an object"); } - const { agentId, limit = DEFAULT_LIMIT } = params as GetAgentMessagesParams; + const { agentId, conversationId, limit = DEFAULT_LIMIT } = params as GetAgentMessagesParams; let { offset } = params as GetAgentMessagesParams; if (!agentId) { throw new RpcError("INVALID_PARAMS", "Missing required param: agentId"); } + const normalizedConversationId = (conversationId ?? "").trim(); + if (!normalizedConversationId) { + throw new RpcError("INVALID_PARAMS", "Missing required param: conversationId"); + } + const resolved = resolveConversationId + ? resolveConversationId(agentId, conversationId) + : { conversationId: normalizedConversationId }; - const sessionPath = resolveSessionPath(agentId); - if (!existsSync(sessionPath)) { - throw new RpcError("AGENT_NOT_FOUND", `No session found for agent: ${agentId}`); + const resolvedConversationId = resolved?.conversationId?.trim() ?? ""; + if (!resolvedConversationId) { + throw new RpcError("INVALID_PARAMS", "Unable to resolve conversationId"); } - const session = new SessionManager({ sessionId: agentId }); + const storageOptions = resolved?.storageAgentId + ? { agentId: resolved.storageAgentId } + : undefined; + + const sessionPath = resolveSessionPath(resolvedConversationId, storageOptions); + if (!existsSync(sessionPath)) { + throw new RpcError("AGENT_NOT_FOUND", `No session found for conversation: ${resolvedConversationId}`); + } + + const session = new SessionManager({ + sessionId: resolvedConversationId, + ...(storageOptions ?? {}), + }); const allMessages = session.loadMessagesForDisplay(); const total = allMessages.length; const contextWindowTokens = session.getMeta()?.contextWindowTokens ?? session.getContextWindowTokens(); @@ -40,6 +67,6 @@ export function createGetAgentMessagesHandler(): RpcHandler { const sliced = allMessages.slice(offset, offset + limit); - return { messages: sliced, total, offset, limit, contextWindowTokens }; + return { messages: sliced, total, offset, limit, conversationId: resolvedConversationId, contextWindowTokens }; }; } diff --git a/packages/core/src/hub/rpc/handlers/get-hub-info.ts b/packages/core/src/hub/rpc/handlers/get-hub-info.ts index 46da8784..846e74ff 100644 --- a/packages/core/src/hub/rpc/handlers/get-hub-info.ts +++ b/packages/core/src/hub/rpc/handlers/get-hub-info.ts @@ -4,7 +4,7 @@ interface HubLike { hubId: string; url: string; connectionState: string; - listAgents(): string[]; + listConversations(): string[]; } export function createGetHubInfoHandler(hub: HubLike): RpcHandler { @@ -12,6 +12,6 @@ export function createGetHubInfoHandler(hub: HubLike): RpcHandler { hubId: hub.hubId, url: hub.url, connectionState: hub.connectionState, - agentCount: hub.listAgents().length, + agentCount: hub.listConversations().length, }); } diff --git a/packages/core/src/hub/rpc/handlers/list-agents.ts b/packages/core/src/hub/rpc/handlers/list-agents.ts deleted file mode 100644 index ccd5c3e2..00000000 --- a/packages/core/src/hub/rpc/handlers/list-agents.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { RpcHandler } from "../dispatcher.js"; - -interface HubLike { - listAgents(): string[]; - getAgent(id: string): { closed: boolean } | undefined; -} - -export function createListAgentsHandler(hub: HubLike): RpcHandler { - return () => { - const agents = hub.listAgents().map((id) => { - const agent = hub.getAgent(id); - return { id, closed: agent?.closed ?? true }; - }); - return { agents }; - }; -} diff --git a/packages/core/src/hub/rpc/handlers/list-conversations.test.ts b/packages/core/src/hub/rpc/handlers/list-conversations.test.ts new file mode 100644 index 00000000..20b8c20b --- /dev/null +++ b/packages/core/src/hub/rpc/handlers/list-conversations.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, vi } from "vitest"; +import { createListConversationsHandler } from "./list-conversations.js"; + +describe("createListConversationsHandler", () => { + it("lists conversations with closed status", () => { + const listConversations = vi.fn(() => ["conv-1", "conv-2"]); + const getConversation = vi.fn((id: string) => (id === "conv-1" ? { closed: false } : { closed: true })); + const handler = createListConversationsHandler({ listConversations, getConversation }); + + const result = handler(undefined, "device-1") as { + conversations: Array<{ id: string; closed: boolean }>; + }; + + expect(result).toEqual({ + conversations: [ + { id: "conv-1", closed: false }, + { id: "conv-2", closed: true }, + ], + }); + }); + + it("defaults closed=true when conversation is missing", () => { + const listConversations = vi.fn(() => ["conv-1"]); + const getConversation = vi.fn(() => undefined); + const handler = createListConversationsHandler({ listConversations, getConversation }); + + const result = handler(undefined, "device-1") as { + conversations: Array<{ id: string; closed: boolean }>; + }; + + expect(result).toEqual({ + conversations: [{ id: "conv-1", closed: true }], + }); + }); +}); diff --git a/packages/core/src/hub/rpc/handlers/list-conversations.ts b/packages/core/src/hub/rpc/handlers/list-conversations.ts new file mode 100644 index 00000000..42508ca6 --- /dev/null +++ b/packages/core/src/hub/rpc/handlers/list-conversations.ts @@ -0,0 +1,16 @@ +import type { RpcHandler } from "../dispatcher.js"; + +interface HubLike { + listConversations(): string[]; + getConversation(id: string): { closed: boolean } | undefined; +} + +export function createListConversationsHandler(hub: HubLike): RpcHandler { + return () => { + const conversations = hub.listConversations().map((id) => { + const conversation = hub.getConversation(id); + return { id, closed: conversation?.closed ?? true }; + }); + return { conversations }; + }; +} diff --git a/packages/core/src/hub/rpc/handlers/verify.test.ts b/packages/core/src/hub/rpc/handlers/verify.test.ts new file mode 100644 index 00000000..9ed05837 --- /dev/null +++ b/packages/core/src/hub/rpc/handlers/verify.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from "vitest"; +import { createVerifyHandler } from "./verify.js"; +import { RpcError } from "../dispatcher.js"; +import type { DeviceStore } from "../../device-store.js"; + +function createDeviceStoreStub() { + return { + isAllowed: vi.fn(), + consumeToken: vi.fn(), + allowDevice: vi.fn(), + } as unknown as DeviceStore; +} + +describe("createVerifyHandler", () => { + it("returns existing authorized conversation scope without consuming token", async () => { + const deviceStore = createDeviceStoreStub(); + const storeApi = deviceStore as unknown as { + isAllowed: ReturnType; + consumeToken: ReturnType; + allowDevice: ReturnType; + }; + storeApi.isAllowed.mockReturnValue({ + agentId: "agent-1", + conversationIds: ["conv-1"], + }); + + const onConfirmDevice = vi.fn(async () => true); + const handler = createVerifyHandler({ + hubId: "hub-1", + deviceStore, + resolveMainConversationId: () => "conv-1", + onConfirmDevice, + }); + + const result = await handler({}, "dev-1"); + expect(result).toEqual({ + hubId: "hub-1", + agentId: "agent-1", + conversationId: "conv-1", + isNewDevice: false, + }); + expect(storeApi.consumeToken).not.toHaveBeenCalled(); + expect(onConfirmDevice).not.toHaveBeenCalled(); + }); + + it("consumes token, confirms device, and stores conversation scope", async () => { + const deviceStore = createDeviceStoreStub(); + const storeApi = deviceStore as unknown as { + isAllowed: ReturnType; + consumeToken: ReturnType; + allowDevice: ReturnType; + }; + storeApi.isAllowed.mockReturnValue(null); + storeApi.consumeToken.mockReturnValue({ + agentId: "agent-2", + conversationId: "conv-2", + }); + + const onConfirmDevice = vi.fn(async () => true); + const handler = createVerifyHandler({ + hubId: "hub-2", + deviceStore, + resolveMainConversationId: () => "conv-2", + onConfirmDevice, + }); + + const result = await handler({ token: "token-2" }, "dev-2"); + expect(result).toEqual({ + hubId: "hub-2", + agentId: "agent-2", + conversationId: "conv-2", + isNewDevice: true, + }); + expect(onConfirmDevice).toHaveBeenCalledWith("dev-2", "agent-2", "conv-2", undefined); + expect(storeApi.allowDevice).toHaveBeenCalledWith("dev-2", "agent-2", "conv-2", undefined); + }); + + it("throws REJECTED when user denies device confirmation", async () => { + const deviceStore = createDeviceStoreStub(); + const storeApi = deviceStore as unknown as { + isAllowed: ReturnType; + consumeToken: ReturnType; + allowDevice: ReturnType; + }; + storeApi.isAllowed.mockReturnValue(null); + storeApi.consumeToken.mockReturnValue({ + agentId: "agent-3", + conversationId: "conv-3", + }); + + const handler = createVerifyHandler({ + hubId: "hub-3", + deviceStore, + onConfirmDevice: async () => false, + }); + + await expect(handler({ token: "token-3" }, "dev-3")).rejects.toMatchObject({ + code: "REJECTED", + } satisfies Partial); + expect(storeApi.allowDevice).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/hub/rpc/handlers/verify.ts b/packages/core/src/hub/rpc/handlers/verify.ts index 67bcfda8..e6bb09e5 100644 --- a/packages/core/src/hub/rpc/handlers/verify.ts +++ b/packages/core/src/hub/rpc/handlers/verify.ts @@ -5,8 +5,14 @@ import type { DeviceStore, DeviceMeta } from "../../device-store.js"; interface VerifyContext { hubId: string; deviceStore: DeviceStore; + resolveMainConversationId?: (agentId: string) => string | undefined; /** Called for first-time connections. Returns true if user approves, false if rejected. */ - onConfirmDevice: (deviceId: string, agentId: string, meta?: DeviceMeta) => Promise; + onConfirmDevice: ( + deviceId: string, + agentId: string, + conversationId: string, + meta?: DeviceMeta, + ) => Promise; } interface VerifyParams { @@ -21,7 +27,19 @@ export function createVerifyHandler(ctx: VerifyContext): RpcHandler { // 1. Already in whitelist → pass through (reconnection, no confirmation needed) const allowed = ctx.deviceStore.isAllowed(from); if (allowed) { - return { hubId: ctx.hubId, agentId: allowed.agentId, isNewDevice: false }; + const preferredConversationId = allowed.conversationIds[0]; + const mainConversationId = ctx.resolveMainConversationId?.(allowed.agentId) + ?? preferredConversationId + ?? allowed.agentId; + const conversationId = allowed.conversationIds.includes(mainConversationId) + ? mainConversationId + : preferredConversationId ?? mainConversationId; + return { + hubId: ctx.hubId, + agentId: allowed.agentId, + conversationId, + isNewDevice: false, + }; } // 2. Validate token @@ -35,13 +53,19 @@ export function createVerifyHandler(ctx: VerifyContext): RpcHandler { } // 3. Token valid → await Desktop user confirmation - const confirmed = await ctx.onConfirmDevice(from, result.agentId, meta); + const confirmed = await ctx.onConfirmDevice(from, result.agentId, result.conversationId, meta); if (!confirmed) { throw new RpcError("REJECTED", "Connection rejected by user"); } // 4. User confirmed → add to whitelist (with device metadata) - ctx.deviceStore.allowDevice(from, result.agentId, meta); - return { hubId: ctx.hubId, agentId: result.agentId, isNewDevice: true }; + ctx.deviceStore.allowDevice(from, result.agentId, result.conversationId, meta); + const mainConversationId = ctx.resolveMainConversationId?.(result.agentId) ?? result.conversationId; + return { + hubId: ctx.hubId, + agentId: result.agentId, + conversationId: mainConversationId, + isNewDevice: true, + }; }; } diff --git a/packages/core/src/hub/rpc/index.ts b/packages/core/src/hub/rpc/index.ts index fecb5e20..4be90f71 100644 --- a/packages/core/src/hub/rpc/index.ts +++ b/packages/core/src/hub/rpc/index.ts @@ -1,9 +1,9 @@ export { RpcDispatcher, RpcError, type RpcHandler } from "./dispatcher.js"; export { createGetAgentMessagesHandler } from "./handlers/get-agent-messages.js"; export { createGetHubInfoHandler } from "./handlers/get-hub-info.js"; -export { createListAgentsHandler } from "./handlers/list-agents.js"; -export { createCreateAgentHandler } from "./handlers/create-agent.js"; -export { createDeleteAgentHandler } from "./handlers/delete-agent.js"; +export { createListConversationsHandler } from "./handlers/list-conversations.js"; +export { createCreateConversationHandler } from "./handlers/create-conversation.js"; +export { createDeleteConversationHandler } from "./handlers/delete-conversation.js"; export { createUpdateGatewayHandler } from "./handlers/update-gateway.js"; export { createGetLastHeartbeatHandler } from "./handlers/get-last-heartbeat.js"; export { createSetHeartbeatsHandler } from "./handlers/set-heartbeats.js"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 93b6b560..5ac40e6e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -41,11 +41,11 @@ export { type GetAgentMessagesParams, type GetAgentMessagesResult, type GetHubInfoResult, - type ListAgentsResult, - type CreateAgentParams, - type CreateAgentResult, - type DeleteAgentParams, - type DeleteAgentResult, + type ListConversationsResult, + type CreateConversationParams, + type CreateConversationResult, + type DeleteConversationParams, + type DeleteConversationResult, type UpdateGatewayParams, type UpdateGatewayResult, type VerifyParams, diff --git a/packages/hooks/src/use-chat.ts b/packages/hooks/src/use-chat.ts index 3bf344e2..842034b6 100644 --- a/packages/hooks/src/use-chat.ts +++ b/packages/hooks/src/use-chat.ts @@ -56,6 +56,7 @@ export interface Message { role: "user" | "assistant" | "toolResult" | "system"; content: ContentBlock[]; agentId: string; + conversationId: string; stopReason?: string; toolCallId?: string; toolName?: string; @@ -174,8 +175,21 @@ export function useChat() { const isStreaming = streamingIds.size > 0; + const reset = useCallback(() => { + setMessages([]); + setStreamingIds(new Set()); + setPendingApprovals([]); + setError(null); + setHasMore(false); + setContextWindowTokens(undefined); + }, []); + /** Convert raw AgentMessageItem[] → Message[] */ - const convertMessages = useCallback((raw: AgentMessageItem[], agentId: string): Message[] => { + const convertMessages = useCallback(( + raw: AgentMessageItem[], + agentId: string, + conversationId: string, + ): Message[] => { const toolCallArgsMap = new Map }>(); for (const m of raw) { if (m.role === "assistant") { @@ -190,9 +204,23 @@ export function useChat() { const loaded: Message[] = []; for (const m of raw) { if (m.role === "user") { - loaded.push({ id: uuidv7(), role: "user", content: toContentBlocks(m.content), agentId, source: m.source }); + loaded.push({ + id: uuidv7(), + role: "user", + content: toContentBlocks(m.content), + agentId, + conversationId, + source: m.source, + }); } else if (m.role === "assistant") { - loaded.push({ id: uuidv7(), role: "assistant", content: toContentBlocks(m.content), agentId, stopReason: m.stopReason }); + loaded.push({ + id: uuidv7(), + role: "assistant", + content: toContentBlocks(m.content), + agentId, + conversationId, + stopReason: m.stopReason, + }); } else if (m.role === "toolResult") { const callInfo = toolCallArgsMap.get(m.toolCallId); loaded.push({ @@ -200,6 +228,7 @@ export function useChat() { role: "toolResult", content: toContentBlocks(m.content), agentId, + conversationId, toolCallId: m.toolCallId, toolName: m.toolName, toolArgs: callInfo?.args, @@ -215,9 +244,10 @@ export function useChat() { const setHistory = useCallback(( raw: AgentMessageItem[], agentId: string, + conversationId: string, meta?: { total: number; offset: number; contextWindowTokens?: number }, ) => { - const loaded = convertMessages(raw, agentId); + const loaded = convertMessages(raw, agentId, conversationId); setMessages(loaded); if (meta) { setHasMore(meta.offset > 0); @@ -231,9 +261,10 @@ export function useChat() { const prependHistory = useCallback(( raw: AgentMessageItem[], agentId: string, + conversationId: string, meta: { total: number; offset: number; contextWindowTokens?: number }, ) => { - const older = convertMessages(raw, agentId); + const older = convertMessages(raw, agentId, conversationId); setMessages((prev) => [...older, ...prev]); setHasMore(meta.offset > 0); if (meta.contextWindowTokens !== undefined) { @@ -242,16 +273,29 @@ export function useChat() { }, [convertMessages]); /** Add a user message */ - const addUserMessage = useCallback((text: string, agentId: string, source?: MessageSource) => { + const addUserMessage = useCallback(( + text: string, + agentId: string, + conversationId: string, + source?: MessageSource, + ) => { setMessages((prev) => [ ...prev, - { id: uuidv7(), role: "user", content: [{ type: "text", text }], agentId, source }, + { + id: uuidv7(), + role: "user", + content: [{ type: "text", text }], + agentId, + conversationId, + source, + }, ]); }, []); /** Process a StreamPayload → update messages + streamingIds */ const handleStream = useCallback((payload: StreamPayload) => { const { event } = payload; + const conversationId = payload.conversationId; switch (event.type) { case "message_start": { @@ -260,6 +304,7 @@ export function useChat() { role: "assistant", content: [], agentId: payload.agentId, + conversationId, }; const content = extractContent(event); if (content.length) newMsg.content = content; @@ -285,7 +330,12 @@ export function useChat() { setMessages((prev) => prev.map((m) => { if (m.id === payload.streamId) return { ...m, content, stopReason }; - if (m.role === "toolResult" && m.toolStatus === "running" && m.agentId === payload.agentId) { + if ( + m.role === "toolResult" + && m.toolStatus === "running" + && m.agentId === payload.agentId + && m.conversationId === conversationId + ) { return { ...m, toolStatus: "interrupted" as ToolStatus }; } return m; @@ -306,6 +356,7 @@ export function useChat() { role: "toolResult", content: [], agentId: payload.agentId, + conversationId, toolCallId: event.toolCallId, toolName: event.toolName, toolArgs: event.args as Record | undefined, @@ -358,6 +409,7 @@ export function useChat() { role: "system", content: [], agentId: payload.agentId, + conversationId, systemType: "compaction", compaction: { removed: ce.removed, @@ -394,6 +446,7 @@ export function useChat() { error, // State control (for transport layer to call) setError, + reset, setHistory, prependHistory, addUserMessage, diff --git a/packages/hooks/src/use-gateway-chat.ts b/packages/hooks/src/use-gateway-chat.ts index 5b98efa8..f5d6f868 100644 --- a/packages/hooks/src/use-gateway-chat.ts +++ b/packages/hooks/src/use-gateway-chat.ts @@ -17,9 +17,10 @@ interface UseGatewayChatOptions { client: GatewayClient; hubId: string; agentId: string; + conversationId: string; } -export function useGatewayChat({ client, hubId, agentId }: UseGatewayChatOptions) { +export function useGatewayChat({ client, hubId, agentId, conversationId }: UseGatewayChatOptions) { const chat = useChat(); const [isLoading, setIsLoading] = useState(false); const [isLoadingHistory, setIsLoadingHistory] = useState(true); @@ -32,10 +33,11 @@ export function useGatewayChat({ client, hubId, agentId }: UseGatewayChatOptions client .request(hubId, "getAgentMessages", { agentId, + conversationId, limit: DEFAULT_MESSAGES_LIMIT, }) .then((result) => { - chat.setHistory(result.messages, agentId, { + chat.setHistory(result.messages, agentId, result.conversationId, { total: result.total, offset: result.offset, contextWindowTokens: result.contextWindowTokens, @@ -44,13 +46,16 @@ export function useGatewayChat({ client, hubId, agentId }: UseGatewayChatOptions }) .catch(() => {}) .finally(() => setIsLoadingHistory(false)); - }, [client, hubId, agentId]); + }, [client, hubId, agentId, conversationId]); // Subscribe to events useEffect(() => { client.onMessage((msg) => { if (msg.action === StreamAction) { const payload = msg.payload as StreamPayload; + if (payload.agentId !== agentId || payload.conversationId !== conversationId) { + return; + } if (payload.event.type === "agent_error") { const errorMsg = (payload.event as { message?: string }).message ?? "Unknown error"; chat.setError({ code: "AGENT_ERROR", message: errorMsg }); @@ -63,7 +68,11 @@ export function useGatewayChat({ client, hubId, agentId }: UseGatewayChatOptions return; } if (msg.action === ExecApprovalRequestAction) { - chat.addApproval(msg.payload as ExecApprovalRequestPayload); + const approval = msg.payload as ExecApprovalRequestPayload; + if (approval.agentId !== agentId || approval.conversationId !== conversationId) { + return; + } + chat.addApproval(approval); return; } if (msg.action === "error") { @@ -72,18 +81,22 @@ export function useGatewayChat({ client, hubId, agentId }: UseGatewayChatOptions } }); return () => { client.onMessage(() => {}); }; - }, [client]); + }, [client, agentId, conversationId]); const sendMessage = useCallback( (text: string) => { const trimmed = text.trim(); if (!trimmed) return; - chat.addUserMessage(trimmed, agentId); + chat.addUserMessage(trimmed, agentId, conversationId, { type: "local" }); chat.setError(null); - client.send(hubId, "message", { agentId, content: trimmed }); + client.send(hubId, "message", { + agentId, + conversationId, + content: trimmed, + }); setIsLoading(true); }, - [client, hubId, agentId], + [client, hubId, agentId, conversationId], ); const loadMore = useCallback(async () => { @@ -96,9 +109,9 @@ export function useGatewayChat({ client, hubId, agentId }: UseGatewayChatOptions const newOffset = Math.max(0, currentOffset - DEFAULT_MESSAGES_LIMIT); const limit = currentOffset - newOffset; const result = await client.request( - hubId, "getAgentMessages", { agentId, offset: newOffset, limit }, + hubId, "getAgentMessages", { agentId, conversationId, offset: newOffset, limit }, ); - chat.prependHistory(result.messages, agentId, { + chat.prependHistory(result.messages, agentId, result.conversationId, { total: result.total, offset: result.offset, contextWindowTokens: result.contextWindowTokens, @@ -110,7 +123,7 @@ export function useGatewayChat({ client, hubId, agentId }: UseGatewayChatOptions isLoadingMoreRef.current = false; setIsLoadingMore(false); } - }, [client, hubId, agentId]); + }, [client, hubId, agentId, conversationId]); const resolveApproval = useCallback( (approvalId: string, decision: ApprovalDecision) => { diff --git a/packages/hooks/src/use-gateway-connection.ts b/packages/hooks/src/use-gateway-connection.ts index 525b512b..0248aa7d 100644 --- a/packages/hooks/src/use-gateway-connection.ts +++ b/packages/hooks/src/use-gateway-connection.ts @@ -14,6 +14,7 @@ export interface ConnectionIdentity { gateway: string; hubId: string; agentId: string; + conversationId?: string; } function loadIdentity(): ConnectionIdentity | null { @@ -21,7 +22,14 @@ function loadIdentity(): ConnectionIdentity | null { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return null; const parsed = JSON.parse(raw); - if (parsed.gateway && parsed.hubId && parsed.agentId) return parsed; + if ( + parsed.gateway + && parsed.hubId + && parsed.agentId + && (parsed.conversationId === undefined || typeof parsed.conversationId === "string") + ) { + return parsed; + } return null; } catch { return null; @@ -96,6 +104,7 @@ export function useGatewayConnection(): UseGatewayConnectionReturn { const [identity, setIdentity] = useState(null); const [error, setError] = useState(null); const clientRef = useRef(null); + const verifiedIdentityRef = useRef(null); const disconnectingRef = useRef(false); const pairingKeyRef = useRef(0); @@ -115,13 +124,25 @@ export function useGatewayConnection(): UseGatewayConnectionReturn { hubId: id.hubId, ...(token ? { token } : {}), }) + .onVerified((result) => { + if (disconnectingRef.current) return; + verifiedIdentityRef.current = { + gateway: id.gateway, + hubId: result.hubId, + agentId: result.agentId, + conversationId: + id.conversationId + ?? result.conversationId, + }; + }) .onStateChange((state: ConnectionState) => { console.log("[GatewayConnection] state:", state); if (disconnectingRef.current) return; setConnectionState(state); if (state === "registered") { - saveIdentity(id); - setIdentity(id); + const resolvedIdentity = verifiedIdentityRef.current ?? id; + saveIdentity(resolvedIdentity); + setIdentity(resolvedIdentity); setPageState("connected"); } }) @@ -129,6 +150,7 @@ export function useGatewayConnection(): UseGatewayConnectionReturn { console.log("[GatewayConnection] error:", err.message); if (disconnectingRef.current) return; pairingKeyRef.current += 1; + verifiedIdentityRef.current = null; clearIdentity(); setIdentity(null); setError(err.message); @@ -149,8 +171,10 @@ export function useGatewayConnection(): UseGatewayConnectionReturn { if (clientRef.current) { clientRef.current.disconnect(); clientRef.current = null; + verifiedIdentityRef.current = null; setTimeout(doConnect, 300); } else { + verifiedIdentityRef.current = null; doConnect(); } }, @@ -175,6 +199,7 @@ export function useGatewayConnection(): UseGatewayConnectionReturn { clearTimeout(timer); clientRef.current?.disconnect(); clientRef.current = null; + verifiedIdentityRef.current = null; }; }, []); @@ -183,6 +208,7 @@ export function useGatewayConnection(): UseGatewayConnectionReturn { pairingKeyRef.current += 1; clientRef.current?.disconnect(); clientRef.current = null; + verifiedIdentityRef.current = null; clearIdentity(); setIdentity(null); setPageState("not-connected"); diff --git a/packages/sdk/src/actions/exec-approval.ts b/packages/sdk/src/actions/exec-approval.ts index cd098273..0b314d77 100644 --- a/packages/sdk/src/actions/exec-approval.ts +++ b/packages/sdk/src/actions/exec-approval.ts @@ -14,6 +14,8 @@ export interface ExecApprovalRequestPayload { approvalId: string; /** Agent that initiated the command */ agentId: string; + /** Conversation ID that initiated the approval request. */ + conversationId: string; /** Shell command requiring approval */ command: string; /** Working directory */ diff --git a/packages/sdk/src/actions/index.ts b/packages/sdk/src/actions/index.ts index 13ad3a55..04074de5 100644 --- a/packages/sdk/src/actions/index.ts +++ b/packages/sdk/src/actions/index.ts @@ -20,11 +20,11 @@ export { type GetAgentMessagesParams, type GetAgentMessagesResult, type GetHubInfoResult, - type ListAgentsResult, - type CreateAgentParams, - type CreateAgentResult, - type DeleteAgentParams, - type DeleteAgentResult, + type ListConversationsResult, + type CreateConversationParams, + type CreateConversationResult, + type DeleteConversationParams, + type DeleteConversationResult, type UpdateGatewayParams, type UpdateGatewayResult, type DeviceMeta, diff --git a/packages/sdk/src/actions/rpc.ts b/packages/sdk/src/actions/rpc.ts index a49f1579..a6a5827e 100644 --- a/packages/sdk/src/actions/rpc.ts +++ b/packages/sdk/src/actions/rpc.ts @@ -66,6 +66,8 @@ export const DEFAULT_MESSAGES_LIMIT = 200; /** getAgentMessages - request params */ export interface GetAgentMessagesParams { agentId: string; + /** Conversation ID to read. */ + conversationId: string; offset?: number; limit?: number; } @@ -91,6 +93,8 @@ export interface GetAgentMessagesResult { total: number; offset: number; limit: number; + /** Conversation ID used by the server. */ + conversationId: string; /** Context window size (tokens) used by this session */ contextWindowTokens?: number; } @@ -103,28 +107,29 @@ export interface GetHubInfoResult { agentCount: number; } -/** listAgents - no params needed */ -export interface ListAgentsResult { - agents: { id: string; closed: boolean }[]; +/** listConversations - no params needed */ +export interface ListConversationsResult { + conversations: { id: string; closed: boolean }[]; } -/** createAgent - request params */ -export interface CreateAgentParams { +/** createConversation - request params (create a conversation, optionally under a specific agent) */ +export interface CreateConversationParams { id?: string; + agentId?: string; } -/** createAgent - response payload */ -export interface CreateAgentResult { +/** createConversation - response payload */ +export interface CreateConversationResult { id: string; } -/** deleteAgent - request params */ -export interface DeleteAgentParams { +/** deleteConversation - request params */ +export interface DeleteConversationParams { id: string; } -/** deleteAgent - response payload */ -export interface DeleteAgentResult { +/** deleteConversation - response payload */ +export interface DeleteConversationResult { ok: boolean; } @@ -157,4 +162,6 @@ export interface VerifyParams { export interface VerifyResult { hubId: string; agentId: string; + /** Authorized conversation scope for this device. */ + conversationId: string; } diff --git a/packages/sdk/src/actions/stream.ts b/packages/sdk/src/actions/stream.ts index cf1349d3..777b9734 100644 --- a/packages/sdk/src/actions/stream.ts +++ b/packages/sdk/src/actions/stream.ts @@ -62,6 +62,8 @@ export type AgentErrorEvent = { export interface StreamPayload { streamId: string; agentId: string; + /** Conversation ID of this stream event. */ + conversationId: string; event: AgentEvent | CompactionEvent | AgentErrorEvent; } diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index cf7bf932..b94b3a64 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -234,7 +234,16 @@ export class GatewayClient { } /** Hub 验证成功回调 */ - onVerified(callback: (result: { hubId: string; agentId: string; isNewDevice?: boolean }) => void): this { + onVerified( + callback: ( + result: { + hubId: string; + agentId: string; + conversationId: string; + isNewDevice?: boolean; + } + ) => void, + ): this { this.callbacks.onVerified = callback; return this; } @@ -318,12 +327,17 @@ export class GatewayClient { platform: navigator.platform, language: navigator.language, } : undefined; - this.request<{ hubId: string; agentId: string; isNewDevice?: boolean }>( - this.options.hubId, - "verify", - { token: this.options.token, meta }, - this.options.verifyTimeout, - ) + this.request<{ + hubId: string; + agentId: string; + conversationId: string; + isNewDevice?: boolean; + }>( + this.options.hubId, + "verify", + { token: this.options.token, meta }, + this.options.verifyTimeout, + ) .then((result) => { // Verify succeeded — now expose "registered" to upper layer this.callbacks.onVerified?.(result); diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index f8a3f693..9a2a1320 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -110,7 +110,14 @@ export interface GatewayClientCallbacks { onConnect?: (socketId: string) => void; onDisconnect?: (reason: string) => void; onRegistered?: (deviceId: string) => void; - onVerified?: (result: { hubId: string; agentId: string; isNewDevice?: boolean }) => void; + onVerified?: ( + result: { + hubId: string; + agentId: string; + conversationId: string; + isNewDevice?: boolean; + } + ) => void; onMessage?: (message: RoutedMessage) => void; onSendError?: (error: SendErrorResponse) => void; onPong?: (data: string) => void; diff --git a/packages/store/src/connection.ts b/packages/store/src/connection.ts index ca820c0d..726cb39b 100644 --- a/packages/store/src/connection.ts +++ b/packages/store/src/connection.ts @@ -3,6 +3,7 @@ export interface ConnectionInfo { gateway: string hubId: string agentId: string + conversationId?: string token: string expires: number } @@ -15,12 +16,13 @@ function isConnectionInfo(obj: unknown): obj is ConnectionInfo { typeof o.gateway === "string" && typeof o.hubId === "string" && typeof o.agentId === "string" && + (o.conversationId === undefined || typeof o.conversationId === "string") && typeof o.token === "string" && typeof o.expires === "number" ) } -// Parse multica://connect?gateway=...&hub=...&agent=...&token=...&exp=... URL format +// Parse multica://connect?gateway=...&hub=...&agent=...&conversation=...&token=...&exp=... URL format // Uses string prefix + URLSearchParams to avoid cross-engine URL hostname differences function parseConnectionUrl(input: string): ConnectionInfo | null { const prefix = "multica://connect?" @@ -30,6 +32,7 @@ function parseConnectionUrl(input: string): ConnectionInfo | null { const gateway = params.get("gateway") const hubId = params.get("hub") const agentId = params.get("agent") + const conversationId = params.get("conversation") const token = params.get("token") const exp = params.get("exp") if (!gateway || !hubId || !agentId || !token || !exp) return null @@ -38,6 +41,7 @@ function parseConnectionUrl(input: string): ConnectionInfo | null { gateway, hubId, agentId, + ...(conversationId ? { conversationId } : {}), token, expires: Number(exp), } diff --git a/packages/store/src/types.ts b/packages/store/src/types.ts index 213b3046..7b070adb 100644 --- a/packages/store/src/types.ts +++ b/packages/store/src/types.ts @@ -44,6 +44,7 @@ export interface Message { role: "user" | "assistant" | "toolResult" | "system" content: ContentBlock[] agentId: string + conversationId?: string stopReason?: string toolCallId?: string toolName?: string diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index bbf154f4..1abe6070 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -170,6 +170,7 @@ export type StreamEventType = export interface StreamEvent { type: StreamEventType agentId: string + conversationId?: string streamId?: string data?: unknown } @@ -185,6 +186,7 @@ export type ApprovalDecision = 'allow-once' | 'allow-always' | 'deny' export interface ExecApprovalRequest { approvalId: string agentId: string + conversationId?: string command: string cwd?: string riskLevel: RiskLevel diff --git a/packages/ui/src/components/device-pairing.tsx b/packages/ui/src/components/device-pairing.tsx index afae275f..ec68c05f 100644 --- a/packages/ui/src/components/device-pairing.tsx +++ b/packages/ui/src/components/device-pairing.tsx @@ -37,6 +37,7 @@ export interface ConnectionIdentity { gateway: string; hubId: string; agentId: string; + conversationId?: string; } export interface DevicePairingProps { @@ -150,7 +151,12 @@ export function DevicePairing({ navigator.vibrate?.(50); setTimeout(() => { onConnect( - { gateway: info.gateway, hubId: info.hubId, agentId: info.agentId }, + { + gateway: info.gateway, + hubId: info.hubId, + agentId: info.agentId, + ...(info.conversationId ? { conversationId: info.conversationId } : {}), + }, info.token, ); }, 600); @@ -183,7 +189,12 @@ export function DevicePairing({ async (data: string) => { const info = parseConnectionCode(data); onConnect( - { gateway: info.gateway, hubId: info.hubId, agentId: info.agentId }, + { + gateway: info.gateway, + hubId: info.hubId, + agentId: info.agentId, + ...(info.conversationId ? { conversationId: info.conversationId } : {}), + }, info.token, ); },