Merge pull request #222 from multica-ai/codex/conversation-fasttrack-1-rebased
refactor: adopt conversation-first architecture across core and apps (rebased)
This commit is contained in:
commit
700e64342c
81 changed files with 3770 additions and 1031 deletions
91
apps/desktop/src/main/electron-env.d.ts
vendored
91
apps/desktop/src/main/electron-env.d.ts
vendored
|
|
@ -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<unknown>
|
||||
getStatus: () => Promise<HubStatus>
|
||||
getAgentInfo: () => Promise<AgentInfo | null>
|
||||
info: () => Promise<unknown>
|
||||
reconnect: (url: string) => Promise<unknown>
|
||||
listAgents: () => Promise<unknown>
|
||||
createAgent: (id?: string) => Promise<unknown>
|
||||
getAgent: (id: string) => Promise<unknown>
|
||||
closeAgent: (id: string) => Promise<unknown>
|
||||
sendMessage: (agentId: string, content: string) => Promise<unknown>
|
||||
registerToken: (token: string, agentId: string, expiresAt: number) => Promise<unknown>
|
||||
onDeviceConfirmRequest: (callback: (deviceId: string, meta?: DeviceMeta) => void) => void
|
||||
getAgentInfo: () => Promise<AgentInfo | null>
|
||||
info: () => Promise<unknown>
|
||||
reconnect: (url: string) => Promise<unknown>
|
||||
listConversations: () => Promise<unknown>
|
||||
createConversation: (id?: string) => Promise<unknown>
|
||||
getConversation: (id: string) => Promise<unknown>
|
||||
closeConversation: (id: string) => Promise<unknown>
|
||||
sendMessage: (agentId: string, content: string, conversationId: string) => Promise<unknown>
|
||||
registerToken: (token: string, agentId: string, conversationId: string, expiresAt: number) => Promise<unknown>
|
||||
onDeviceConfirmRequest: (callback: (deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta) => void) => void
|
||||
offDeviceConfirmRequest: () => void
|
||||
deviceConfirmResponse: (deviceId: string, allowed: boolean) => void
|
||||
listDevices: () => Promise<DeviceEntryInfo[]>
|
||||
|
|
@ -246,13 +251,13 @@ interface ElectronAPI {
|
|||
last: () => Promise<unknown>
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
if (hub) {
|
||||
|
|
@ -47,16 +47,16 @@ export async function initializeHub(): Promise<void> {
|
|||
|
||||
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<AgentInfo[]> => {
|
||||
ipcMain.handle('hub:listConversations', async (): Promise<AgentInfo[]> => {
|
||||
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<boolean>((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)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<HubStatus> => ipcRenderer.invoke('hub:getStatus'),
|
||||
getAgentInfo: (): Promise<AgentInfo | null> => 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<AgentInfo | null> => 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),
|
||||
|
|
|
|||
|
|
@ -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<PendingConfirm | null>(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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string | undefined>(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 (
|
||||
<div className="flex-1 flex items-center justify-center gap-2 text-muted-foreground text-sm">
|
||||
<Loading />
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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 (
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export interface DeviceMeta {
|
|||
export interface DeviceEntry {
|
||||
deviceId: string
|
||||
agentId: string
|
||||
conversationIds: string[]
|
||||
addedAt: number
|
||||
meta?: DeviceMeta
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null)
|
||||
const initRef = useRef(false)
|
||||
const offsetRef = useRef<number | null>(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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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 (
|
||||
<div className="flex h-screen flex-col bg-background text-foreground">
|
||||
<SidebarProvider className="flex-1 overflow-hidden">
|
||||
|
|
@ -304,7 +354,56 @@ export default function Layout() {
|
|||
</div>
|
||||
{chatMounted && (
|
||||
<div className={cn('h-full flex flex-col overflow-hidden', !isOnChat && 'hidden')}>
|
||||
<LocalChat initialPrompt={initialPrompt} />
|
||||
<div className="border-b px-4 py-2 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-xs text-muted-foreground">Session</span>
|
||||
{activeConversationId ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm" className="h-7 max-w-64 justify-start font-mono text-xs" />
|
||||
}
|
||||
>
|
||||
{shortConversationId(activeConversationId)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-80">
|
||||
{agents.length === 0 ? (
|
||||
<DropdownMenuItem disabled>
|
||||
No sessions available
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
agents.map((item) => (
|
||||
<DropdownMenuItem key={item.id} onClick={() => openConversation(item.id)}>
|
||||
<span className="font-mono text-xs truncate">{item.id}</span>
|
||||
{item.id === activeConversationId && <span className="ml-auto text-xs">Current</span>}
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Initializing...</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 shrink-0"
|
||||
onClick={handleCreateConversation}
|
||||
disabled={isCreatingConversation}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
{isCreatingConversation ? 'Creating...' : 'New Session'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{conversationError && (
|
||||
<div className="px-4 py-2 text-xs text-destructive border-b">
|
||||
{conversationError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LocalChat initialPrompt={initialPrompt} conversationId={activeConversationId} />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ interface HubStore {
|
|||
init: () => Promise<void>
|
||||
refresh: () => Promise<void>
|
||||
reconnect: (url: string) => Promise<{ ok: boolean; error?: string }>
|
||||
createConversation: (id?: string) => Promise<AgentInfo | null>
|
||||
closeConversation: (id: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
export const useHubStore = create<HubStore>()((set, get) => ({
|
||||
|
|
@ -45,7 +47,7 @@ export const useHubStore = create<HubStore>()((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<HubStore>()((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<HubStore>()((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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,32 +14,32 @@ export class MessageContextQueue {
|
|||
private readonly pending = new Map<string, MessageContext[]>();
|
||||
private readonly active = new Map<string, MessageContext>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, TelegramUser>;
|
||||
threadRoutes?: Record<string, Record<string, string>>;
|
||||
}
|
||||
|
||||
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<string, TelegramUser>();
|
||||
/** Local per-thread conversation routes, keyed by telegramUserId -> routeKey(chatId:threadId) -> conversationId */
|
||||
private localThreadRoutes = new Map<string, Map<string, string>>();
|
||||
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<TelegramUser | null> {
|
||||
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<string | null> {
|
||||
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<TelegramThreadRouteRow[]>(
|
||||
`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<void> {
|
||||
const routeKey = this.getThreadRouteKey(chatId, threadId);
|
||||
|
||||
if (!this.db.isAvailable()) {
|
||||
await this.ensureLocalStoreLoaded();
|
||||
const routes = this.localThreadRoutes.get(telegramUserId) ?? new Map<string, string>();
|
||||
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<TelegramUser> {
|
||||
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<string, TelegramUser>;
|
||||
const parsed = JSON.parse(data) as LocalStoreFileShape | Record<string, TelegramUser>;
|
||||
const records = (parsed as LocalStoreFileShape).users
|
||||
?? (parsed as Record<string, TelegramUser>);
|
||||
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<string, string>();
|
||||
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<string, Record<string, string>> = {};
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
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<void> {
|
||||
const threadRoute = this.getThreadRoute(ctx);
|
||||
if (!threadRoute) return;
|
||||
await this.userStore.setThreadConversation(
|
||||
user.telegramUserId,
|
||||
threadRoute.chatId,
|
||||
conversationId,
|
||||
threadRoute.threadId,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Lifecycle ──
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
|
|
@ -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 =
|
||||
`<b>Welcome back!</b>\n\n` +
|
||||
`${statusEmoji} Status: <b>${statusText}</b>\n` +
|
||||
`Agent: <code>${user.agentId}</code>\n\n` +
|
||||
`Agent: <code>${user.agentId}</code>\n` +
|
||||
`Conversation: <code>${user.conversationId ?? user.agentId}</code>\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 {
|
|||
`<b>Connection Status</b>\n\n` +
|
||||
`${statusEmoji} <b>${statusLabel}</b>\n\n` +
|
||||
`Hub: <code>${user.hubId}</code>\n` +
|
||||
`Agent: <code>${user.agentId}</code>\n\n` +
|
||||
`Agent: <code>${user.agentId}</code>\n` +
|
||||
`Conversation: <code>${user.conversationId ?? user.agentId}</code>\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 {
|
|||
`<b>Commands</b>\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` +
|
||||
`<b>How to connect</b>\n` +
|
||||
` <b>1.</b> 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<Array<{ id: string; closed: boolean }>> {
|
||||
const result = await this.sendRpc<Record<string, never>, ListConversationsResult>(
|
||||
deviceId,
|
||||
hubId,
|
||||
"listConversations",
|
||||
{},
|
||||
VERIFY_TIMEOUT_MS,
|
||||
"List conversations request timed out",
|
||||
);
|
||||
return result.conversations;
|
||||
}
|
||||
|
||||
private async handleNewConversationCommand(ctx: Context): Promise<void> {
|
||||
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(
|
||||
`<b>\u2705 New conversation created</b>\n\n` +
|
||||
`Conversation: <code>${created.id}</code>\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<void> {
|
||||
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 `<code>${id}</code>${marker ? ` ${marker}` : ""}`;
|
||||
});
|
||||
const extra = conversationIds.length > 20 ? `\n...and ${conversationIds.length - 20} more` : "";
|
||||
|
||||
await ctx.reply(
|
||||
`<b>Available conversations</b>\n\n` +
|
||||
`${lines.join("\n")}${extra}\n\n` +
|
||||
`Use <code>/session <id></code> 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<void> {
|
||||
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(
|
||||
`<b>Current conversation</b>\n\n` +
|
||||
`<code>${current}</code>\n\n` +
|
||||
`Use <code>/session <id></code> 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: <code>${target}</code>\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(
|
||||
`<b>\u2705 Conversation switched</b>\n\n` +
|
||||
`Current conversation: <code>${target}</code>`,
|
||||
{ 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<void> {
|
||||
|
|
@ -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<void> {
|
||||
async sendToTelegram(deviceId: string, text: string, conversationId?: string): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const context = this.messageContexts.release(deviceId);
|
||||
private async clearMessageContext(contextKey: string): Promise<void> {
|
||||
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(
|
||||
`<b>\u2705 Connected successfully!</b>\n\n` +
|
||||
`<b>\u2705 Connected successfully!</b>\n\n` +
|
||||
`Hub: <code>${result.hubId}</code>\n` +
|
||||
`Agent: <code>${result.agentId}</code>\n\n` +
|
||||
`Agent: <code>${result.agentId}</code>\n` +
|
||||
`Conversation: <code>${conversationId}</code>\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);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -232,7 +232,7 @@
|
|||
color: var(--green);
|
||||
}
|
||||
|
||||
/* ── Agents section ── */
|
||||
/* ── Conversations section ── */
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -347,7 +347,7 @@
|
|||
<div class="stat-value" id="stat-state">—</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Agents</div>
|
||||
<div class="stat-label">Conversations</div>
|
||||
<div class="stat-value" id="stat-agents">—</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -359,17 +359,17 @@
|
|||
<button class="btn btn-accent" onclick="updateGateway()">Connect</button>
|
||||
</div>
|
||||
|
||||
<!-- Agents -->
|
||||
<!-- Conversations -->
|
||||
<div class="section-header">
|
||||
<span class="section-title">Agents</span>
|
||||
<button class="btn btn-accent" onclick="createAgent()">
|
||||
<span class="section-title">Conversations</span>
|
||||
<button class="btn btn-accent" onclick="createConversation()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
Create Agent
|
||||
Create Conversation
|
||||
</button>
|
||||
</div>
|
||||
<div class="agent-table" id="agent-table">
|
||||
<div class="agent-table-head">
|
||||
<span>Agent ID</span>
|
||||
<span>Conversation ID</span>
|
||||
<span>Actions</span>
|
||||
</div>
|
||||
<div id="agent-list"></div>
|
||||
|
|
@ -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 = '<div class="empty-state">No agents yet. Create one to get started.</div>';
|
||||
if (conversationsRes.length === 0) {
|
||||
list.innerHTML = '<div class="empty-state">No conversations yet. Create one to get started.</div>';
|
||||
} else {
|
||||
list.innerHTML = agentsRes.map(a =>
|
||||
list.innerHTML = conversationsRes.map(a =>
|
||||
`<div class="agent-row">` +
|
||||
`<div class="agent-id">` +
|
||||
`<span title="${a.id}">${a.id}</span>` +
|
||||
`<button class="copy-btn" onclick="copyText('${a.id}', this)">${copyIcon()}</button>` +
|
||||
`</div>` +
|
||||
`<div class="agent-actions">` +
|
||||
`<button class="btn-danger-ghost btn" onclick="deleteAgent('${a.id}')" title="Delete">${trashIcon()}</button>` +
|
||||
`<button class="btn-danger-ghost btn" onclick="deleteConversation('${a.id}')" title="Delete">${trashIcon()}</button>` +
|
||||
`</div>` +
|
||||
`</div>`
|
||||
).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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,11 +32,12 @@ const ChatPage = () => {
|
|||
/>
|
||||
)}
|
||||
|
||||
{pageState === "connected" && client && identity && (
|
||||
{pageState === "connected" && client && identity && identity.conversationId && (
|
||||
<ConnectedChat
|
||||
client={client}
|
||||
hubId={identity.hubId}
|
||||
agentId={identity.agentId}
|
||||
conversationId={identity.conversationId}
|
||||
onDisconnect={disconnect}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -49,14 +50,16 @@ function ConnectedChat({
|
|||
client,
|
||||
hubId,
|
||||
agentId,
|
||||
conversationId,
|
||||
onDisconnect,
|
||||
}: {
|
||||
client: NonNullable<ReturnType<typeof useGatewayConnection>["client"]>;
|
||||
hubId: string;
|
||||
agentId: string;
|
||||
conversationId: string;
|
||||
onDisconnect: () => void;
|
||||
}) {
|
||||
const chat = useGatewayChat({ client, hubId, agentId });
|
||||
const chat = useGatewayChat({ client, hubId, agentId, conversationId });
|
||||
return <ChatView {...chat} onDisconnect={onDisconnect} />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <id>` 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.
|
||||
|
|
|
|||
37
packages/core/src/agent/run-log.error-detection.test.ts
Normal file
37
packages/core/src/agent/run-log.error-detection.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, unknown>): 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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | 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,
|
||||
|
|
|
|||
|
|
@ -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<RepairReport> {
|
||||
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 } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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/<hash>.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/<hash>.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);
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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 ===
|
||||
|
|
|
|||
|
|
@ -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<typeof vi.fn>;
|
||||
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<string, AgentHarness>();
|
||||
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<string, { hubConversationId: string }>;
|
||||
}).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<typeof vi.fn> }).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<typeof vi.fn> }).createConversation).toHaveBeenCalledTimes(1);
|
||||
expect(harness?.write).toHaveBeenCalledTimes(2);
|
||||
expect(harness?.write.mock.calls[1]?.[0]).toContain("persist-2");
|
||||
|
||||
managerB.stopAll();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
80
packages/core/src/hub/agent-store.test.ts
Normal file
80
packages/core/src/hub/agent-store.test.ts
Normal file
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string>();
|
||||
|
||||
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<string, unknown> {
|
||||
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<string, AgentRecord>();
|
||||
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<string, ConversationRecord>();
|
||||
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<string, AgentRecord>(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);
|
||||
}
|
||||
|
|
|
|||
87
packages/core/src/hub/device-store.test.ts
Normal file
87
packages/core/src/hub/device-store.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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<string>();
|
||||
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<string, TokenEntry>();
|
||||
/** Allowed device IDs (persisted to disk) */
|
||||
private readonly allowedDevices = new Map<string, DeviceEntry>();
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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<string, AsyncAgent>();
|
||||
// Conversation ownership map (conversationId -> logical agentId).
|
||||
private readonly conversationAgents = new Map<string, string>();
|
||||
// Main conversation pointer for each agent (agentId -> mainConversationId).
|
||||
private readonly agentMainConversations = new Map<string, string>();
|
||||
// Runtime profile for each logical agent.
|
||||
private readonly agentProfiles = new Map<string, string>();
|
||||
private readonly agentSenders = new Map<string, string>();
|
||||
private readonly agentStreamIds = new Map<string, string>();
|
||||
private readonly agentStreamCounters = new Map<string, number>();
|
||||
private readonly pendingAssistantStarts = new Map<string, { agentId: string; event: unknown }>();
|
||||
private readonly pendingAssistantStarts = new Map<string, { agentId: string; conversationId: string; event: unknown }>();
|
||||
private readonly suppressedStreamAgents = new Set<string>();
|
||||
private readonly localApprovalHandlers = new Map<string, (payload: ExecApprovalRequest) => 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<boolean>) | null = null;
|
||||
private _onConfirmDevice: (
|
||||
(deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta) => Promise<boolean>
|
||||
) | 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<boolean>) | null): void {
|
||||
setConfirmHandler(
|
||||
handler: (
|
||||
(deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta) => Promise<boolean>
|
||||
) | 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<void> {
|
||||
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<ResponseSuccessPayload>(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<ApprovalResult> => {
|
||||
// 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<string>();
|
||||
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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
}
|
||||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
13
packages/core/src/hub/rpc/handlers/create-conversation.ts
Normal file
13
packages/core/src/hub/rpc/handlers/create-conversation.ts
Normal file
|
|
@ -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 };
|
||||
};
|
||||
}
|
||||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
};
|
||||
}
|
||||
|
|
@ -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 };
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
}
|
||||
|
|
@ -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 }],
|
||||
});
|
||||
});
|
||||
});
|
||||
16
packages/core/src/hub/rpc/handlers/list-conversations.ts
Normal file
16
packages/core/src/hub/rpc/handlers/list-conversations.ts
Normal file
|
|
@ -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 };
|
||||
};
|
||||
}
|
||||
102
packages/core/src/hub/rpc/handlers/verify.test.ts
Normal file
102
packages/core/src/hub/rpc/handlers/verify.test.ts
Normal file
|
|
@ -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<typeof vi.fn>;
|
||||
consumeToken: ReturnType<typeof vi.fn>;
|
||||
allowDevice: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
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<typeof vi.fn>;
|
||||
consumeToken: ReturnType<typeof vi.fn>;
|
||||
allowDevice: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
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<typeof vi.fn>;
|
||||
consumeToken: ReturnType<typeof vi.fn>;
|
||||
allowDevice: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
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<RpcError>);
|
||||
expect(storeApi.allowDevice).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<boolean>;
|
||||
onConfirmDevice: (
|
||||
deviceId: string,
|
||||
agentId: string,
|
||||
conversationId: string,
|
||||
meta?: DeviceMeta,
|
||||
) => Promise<boolean>;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string, { name: string; args: Record<string, unknown> }>();
|
||||
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<string, unknown> | 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,
|
||||
|
|
|
|||
|
|
@ -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<GetAgentMessagesResult>(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<GetAgentMessagesResult>(
|
||||
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) => {
|
||||
|
|
|
|||
|
|
@ -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<ConnectionIdentity | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const clientRef = useRef<GatewayClient | null>(null);
|
||||
const verifiedIdentityRef = useRef<ConnectionIdentity | null>(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");
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export interface Message {
|
|||
role: "user" | "assistant" | "toolResult" | "system"
|
||||
content: ContentBlock[]
|
||||
agentId: string
|
||||
conversationId?: string
|
||||
stopReason?: string
|
||||
toolCallId?: string
|
||||
toolName?: string
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue