fix(chat): isolate concurrent session events by refactoring WebSocket event handling (#365)
Refactored the WebSocket event handling mechanism to use global listeners with session-specific event routing instead of per-session listeners. This prevents event cross-talk when multiple chat sessions run concurrently. Key changes: - Client: Added sessionEventHandlers Map to route events to appropriate sessions - Client: Registered global listeners once per socket connection - Server: Extracted message processing logic into handleMessage method - Server: Improved Hermes session ID tracking with dedicated Map - Server: Added replaceByHermesSessionId for targeted message replacement Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -47,6 +47,228 @@ export interface RunEvent {
|
|||||||
// ============================
|
// ============================
|
||||||
|
|
||||||
let chatRunSocket: Socket | null = null
|
let chatRunSocket: Socket | null = null
|
||||||
|
let globalListenersRegistered = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session event handlers map
|
||||||
|
* Maps session_id to event handling functions for isolating concurrent session streams
|
||||||
|
*/
|
||||||
|
const sessionEventHandlers = new Map<string, {
|
||||||
|
onMessageDelta: (event: RunEvent) => void
|
||||||
|
onReasoningDelta: (event: RunEvent) => void
|
||||||
|
onThinkingDelta: (event: RunEvent) => void
|
||||||
|
onReasoningAvailable: (event: RunEvent) => void
|
||||||
|
onToolStarted: (event: RunEvent) => void
|
||||||
|
onToolCompleted: (event: RunEvent) => void
|
||||||
|
onRunStarted: (event: RunEvent) => void
|
||||||
|
onRunCompleted: (event: RunEvent) => void
|
||||||
|
onRunFailed: (event: RunEvent) => void
|
||||||
|
onCompressionStarted: (event: RunEvent) => void
|
||||||
|
onCompressionCompleted: (event: RunEvent) => void
|
||||||
|
onUsageUpdated: (event: RunEvent) => void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global message.delta event handler
|
||||||
|
* Distributes events to appropriate session based on session_id
|
||||||
|
*/
|
||||||
|
function globalMessageDeltaHandler(event: RunEvent): void {
|
||||||
|
const sid = event.session_id
|
||||||
|
if (!sid) return
|
||||||
|
|
||||||
|
const handlers = sessionEventHandlers.get(sid)
|
||||||
|
if (handlers?.onMessageDelta) {
|
||||||
|
handlers.onMessageDelta(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global reasoning.delta event handler
|
||||||
|
*/
|
||||||
|
function globalReasoningDeltaHandler(event: RunEvent): void {
|
||||||
|
const sid = event.session_id
|
||||||
|
if (!sid) return
|
||||||
|
|
||||||
|
const handlers = sessionEventHandlers.get(sid)
|
||||||
|
if (handlers?.onReasoningDelta) {
|
||||||
|
handlers.onReasoningDelta(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global thinking.delta event handler (alias for reasoning.delta)
|
||||||
|
*/
|
||||||
|
function globalThinkingDeltaHandler(event: RunEvent): void {
|
||||||
|
const sid = event.session_id
|
||||||
|
if (!sid) return
|
||||||
|
|
||||||
|
const handlers = sessionEventHandlers.get(sid)
|
||||||
|
if (handlers?.onThinkingDelta) {
|
||||||
|
handlers.onThinkingDelta(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global reasoning.available event handler
|
||||||
|
*/
|
||||||
|
function globalReasoningAvailableHandler(event: RunEvent): void {
|
||||||
|
const sid = event.session_id
|
||||||
|
if (!sid) return
|
||||||
|
|
||||||
|
const handlers = sessionEventHandlers.get(sid)
|
||||||
|
if (handlers?.onReasoningAvailable) {
|
||||||
|
handlers.onReasoningAvailable(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global tool.started event handler
|
||||||
|
*/
|
||||||
|
function globalToolStartedHandler(event: RunEvent): void {
|
||||||
|
const sid = event.session_id
|
||||||
|
if (!sid) return
|
||||||
|
|
||||||
|
const handlers = sessionEventHandlers.get(sid)
|
||||||
|
if (handlers?.onToolStarted) {
|
||||||
|
handlers.onToolStarted(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global tool.completed event handler
|
||||||
|
*/
|
||||||
|
function globalToolCompletedHandler(event: RunEvent): void {
|
||||||
|
const sid = event.session_id
|
||||||
|
if (!sid) return
|
||||||
|
|
||||||
|
const handlers = sessionEventHandlers.get(sid)
|
||||||
|
if (handlers?.onToolCompleted) {
|
||||||
|
handlers.onToolCompleted(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global run.started event handler
|
||||||
|
*/
|
||||||
|
function globalRunStartedHandler(event: RunEvent): void {
|
||||||
|
const sid = event.session_id
|
||||||
|
if (!sid) return
|
||||||
|
|
||||||
|
const handlers = sessionEventHandlers.get(sid)
|
||||||
|
if (handlers?.onRunStarted) {
|
||||||
|
handlers.onRunStarted(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global run.completed event handler
|
||||||
|
*/
|
||||||
|
function globalRunCompletedHandler(event: RunEvent): void {
|
||||||
|
const sid = event.session_id
|
||||||
|
if (!sid) return
|
||||||
|
|
||||||
|
const handlers = sessionEventHandlers.get(sid)
|
||||||
|
if (handlers?.onRunCompleted) {
|
||||||
|
handlers.onRunCompleted(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-cleanup session handlers on completion
|
||||||
|
sessionEventHandlers.delete(sid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global run.failed event handler
|
||||||
|
*/
|
||||||
|
function globalRunFailedHandler(event: RunEvent): void {
|
||||||
|
const sid = event.session_id
|
||||||
|
if (!sid) return
|
||||||
|
|
||||||
|
const handlers = sessionEventHandlers.get(sid)
|
||||||
|
if (handlers?.onRunFailed) {
|
||||||
|
handlers.onRunFailed(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-cleanup session handlers on failure
|
||||||
|
sessionEventHandlers.delete(sid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global compression.started event handler
|
||||||
|
*/
|
||||||
|
function globalCompressionStartedHandler(event: RunEvent): void {
|
||||||
|
const sid = event.session_id
|
||||||
|
if (!sid) return
|
||||||
|
|
||||||
|
const handlers = sessionEventHandlers.get(sid)
|
||||||
|
if (handlers?.onCompressionStarted) {
|
||||||
|
handlers.onCompressionStarted(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global compression.completed event handler
|
||||||
|
*/
|
||||||
|
function globalCompressionCompletedHandler(event: RunEvent): void {
|
||||||
|
const sid = event.session_id
|
||||||
|
if (!sid) return
|
||||||
|
|
||||||
|
const handlers = sessionEventHandlers.get(sid)
|
||||||
|
if (handlers?.onCompressionCompleted) {
|
||||||
|
handlers.onCompressionCompleted(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global usage.updated event handler
|
||||||
|
*/
|
||||||
|
function globalUsageUpdatedHandler(event: RunEvent): void {
|
||||||
|
const sid = event.session_id
|
||||||
|
if (!sid) return
|
||||||
|
|
||||||
|
const handlers = sessionEventHandlers.get(sid)
|
||||||
|
if (handlers?.onUsageUpdated) {
|
||||||
|
handlers.onUsageUpdated(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register event handlers for a session
|
||||||
|
* @param sessionId - Session ID
|
||||||
|
* @param handlers - Event handling functions
|
||||||
|
* @returns Cleanup function to unregister handlers
|
||||||
|
*/
|
||||||
|
export function registerSessionHandlers(
|
||||||
|
sessionId: string,
|
||||||
|
handlers: {
|
||||||
|
onMessageDelta: (event: RunEvent) => void
|
||||||
|
onReasoningDelta: (event: RunEvent) => void
|
||||||
|
onThinkingDelta: (event: RunEvent) => void
|
||||||
|
onReasoningAvailable: (event: RunEvent) => void
|
||||||
|
onToolStarted: (event: RunEvent) => void
|
||||||
|
onToolCompleted: (event: RunEvent) => void
|
||||||
|
onRunStarted: (event: RunEvent) => void
|
||||||
|
onRunCompleted: (event: RunEvent) => void
|
||||||
|
onRunFailed: (event: RunEvent) => void
|
||||||
|
onCompressionStarted: (event: RunEvent) => void
|
||||||
|
onCompressionCompleted: (event: RunEvent) => void
|
||||||
|
onUsageUpdated: (event: RunEvent) => void
|
||||||
|
}
|
||||||
|
): () => void {
|
||||||
|
sessionEventHandlers.set(sessionId, handlers)
|
||||||
|
|
||||||
|
// Return cleanup function
|
||||||
|
return () => {
|
||||||
|
sessionEventHandlers.delete(sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister event handlers for a session
|
||||||
|
* @param sessionId - Session ID
|
||||||
|
*/
|
||||||
|
export function unregisterSessionHandlers(sessionId: string): void {
|
||||||
|
sessionEventHandlers.delete(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
export function getChatRunSocket(): Socket | null {
|
export function getChatRunSocket(): Socket | null {
|
||||||
return chatRunSocket
|
return chatRunSocket
|
||||||
@@ -59,6 +281,7 @@ export function connectChatRun(): Socket {
|
|||||||
if (chatRunSocket) {
|
if (chatRunSocket) {
|
||||||
chatRunSocket.removeAllListeners()
|
chatRunSocket.removeAllListeners()
|
||||||
chatRunSocket.disconnect()
|
chatRunSocket.disconnect()
|
||||||
|
globalListenersRegistered = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = getBaseUrlValue()
|
const baseUrl = getBaseUrlValue()
|
||||||
@@ -75,6 +298,33 @@ export function connectChatRun(): Socket {
|
|||||||
reconnectionDelayMax: 10000,
|
reconnectionDelayMax: 10000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Register global listeners only once per socket connection
|
||||||
|
if (!globalListenersRegistered) {
|
||||||
|
// Message events
|
||||||
|
chatRunSocket.on('message.delta', globalMessageDeltaHandler)
|
||||||
|
chatRunSocket.on('reasoning.delta', globalReasoningDeltaHandler)
|
||||||
|
chatRunSocket.on('thinking.delta', globalThinkingDeltaHandler)
|
||||||
|
chatRunSocket.on('reasoning.available', globalReasoningAvailableHandler)
|
||||||
|
|
||||||
|
// Tool events
|
||||||
|
chatRunSocket.on('tool.started', globalToolStartedHandler)
|
||||||
|
chatRunSocket.on('tool.completed', globalToolCompletedHandler)
|
||||||
|
|
||||||
|
// Run lifecycle events
|
||||||
|
chatRunSocket.on('run.started', globalRunStartedHandler)
|
||||||
|
chatRunSocket.on('run.failed', globalRunFailedHandler)
|
||||||
|
chatRunSocket.on('run.completed', globalRunCompletedHandler)
|
||||||
|
|
||||||
|
// Compression events
|
||||||
|
chatRunSocket.on('compression.started', globalCompressionStartedHandler)
|
||||||
|
chatRunSocket.on('compression.completed', globalCompressionCompletedHandler)
|
||||||
|
|
||||||
|
// Usage events
|
||||||
|
chatRunSocket.on('usage.updated', globalUsageUpdatedHandler)
|
||||||
|
|
||||||
|
globalListenersRegistered = true
|
||||||
|
}
|
||||||
|
|
||||||
return chatRunSocket
|
return chatRunSocket
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +332,8 @@ export function disconnectChatRun(): void {
|
|||||||
if (chatRunSocket) {
|
if (chatRunSocket) {
|
||||||
chatRunSocket.disconnect()
|
chatRunSocket.disconnect()
|
||||||
chatRunSocket = null
|
chatRunSocket = null
|
||||||
|
globalListenersRegistered = false
|
||||||
|
sessionEventHandlers.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,92 +363,83 @@ export function startRunViaSocket(
|
|||||||
onError: (err: Error) => void,
|
onError: (err: Error) => void,
|
||||||
onStarted?: (runId: string) => void,
|
onStarted?: (runId: string) => void,
|
||||||
): { abort: () => void } {
|
): { abort: () => void } {
|
||||||
const socket = connectChatRun()
|
const sid = body.session_id
|
||||||
|
if (!sid) {
|
||||||
|
throw new Error('session_id is required for startRunViaSocket')
|
||||||
|
}
|
||||||
|
|
||||||
let closed = false
|
let closed = false
|
||||||
|
|
||||||
function cleanup() {
|
// Define event handlers for this session
|
||||||
|
const handlers = {
|
||||||
|
onMessageDelta: (evt: RunEvent) => {
|
||||||
if (closed) return
|
if (closed) return
|
||||||
|
onEvent(evt)
|
||||||
|
},
|
||||||
|
onReasoningDelta: (evt: RunEvent) => {
|
||||||
|
if (closed) return
|
||||||
|
onEvent(evt)
|
||||||
|
},
|
||||||
|
onThinkingDelta: (evt: RunEvent) => {
|
||||||
|
if (closed) return
|
||||||
|
onEvent(evt)
|
||||||
|
},
|
||||||
|
onReasoningAvailable: (evt: RunEvent) => {
|
||||||
|
if (closed) return
|
||||||
|
onEvent(evt)
|
||||||
|
},
|
||||||
|
onToolStarted: (evt: RunEvent) => {
|
||||||
|
if (closed) return
|
||||||
|
onEvent(evt)
|
||||||
|
},
|
||||||
|
onToolCompleted: (evt: RunEvent) => {
|
||||||
|
if (closed) return
|
||||||
|
onEvent(evt)
|
||||||
|
},
|
||||||
|
onRunStarted: (evt: RunEvent) => {
|
||||||
|
if (closed) return
|
||||||
|
onEvent(evt)
|
||||||
|
onStarted?.(evt.run_id || '')
|
||||||
|
},
|
||||||
|
onRunCompleted: (evt: RunEvent) => {
|
||||||
|
if (closed) return
|
||||||
|
onEvent(evt)
|
||||||
closed = true
|
closed = true
|
||||||
socket.off('run.started', onRunStarted)
|
|
||||||
socket.off('run.failed', onRunFailed)
|
|
||||||
socket.off('message.delta', onMessageDelta)
|
|
||||||
socket.off('reasoning.delta', onReasoningDelta)
|
|
||||||
socket.off('thinking.delta', onReasoningDelta)
|
|
||||||
socket.off('reasoning.available', onReasoningAvailable)
|
|
||||||
socket.off('tool.started', onToolStarted)
|
|
||||||
socket.off('tool.completed', onToolCompleted)
|
|
||||||
socket.off('run.completed', onRunCompleted)
|
|
||||||
socket.off('compression.started', onCompressionStarted)
|
|
||||||
socket.off('compression.completed', onCompressionCompleted)
|
|
||||||
socket.off('usage.updated', onUsageUpdated)
|
|
||||||
}
|
|
||||||
|
|
||||||
// All event handlers share the same cleanup logic.
|
|
||||||
// IMPORTANT: The Socket.IO connection is shared across all in-flight runs
|
|
||||||
// (single namespace, single socket). When two sessions run concurrently,
|
|
||||||
// every `startRunViaSocket()` call registers its own `message.delta` /
|
|
||||||
// `tool.*` / `run.*` listeners on the SAME socket, so each event would
|
|
||||||
// fan out to every listener and corrupt the wrong session's transcript.
|
|
||||||
// The server tags every payload with `session_id`; we filter here so each
|
|
||||||
// run only sees its own events. We also accept untagged events (for
|
|
||||||
// backwards compatibility) when no session_id was provided in the request.
|
|
||||||
const expectedSid = body.session_id
|
|
||||||
const handleEvent = (event: RunEvent) => {
|
|
||||||
if (closed) return
|
|
||||||
// Filter events by session_id to prevent cross-session contamination
|
|
||||||
if (expectedSid && event.session_id && event.session_id !== expectedSid) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
onEvent(event)
|
|
||||||
} finally {
|
|
||||||
if (event.event === 'run.completed' || event.event === 'run.failed') {
|
|
||||||
cleanup()
|
|
||||||
onDone()
|
onDone()
|
||||||
}
|
},
|
||||||
}
|
onRunFailed: (evt: RunEvent) => {
|
||||||
|
if (closed) return
|
||||||
|
onEvent(evt)
|
||||||
|
closed = true
|
||||||
|
onError(new Error(evt.error || 'Run failed'))
|
||||||
|
},
|
||||||
|
onCompressionStarted: (evt: RunEvent) => {
|
||||||
|
if (closed) return
|
||||||
|
onEvent(evt)
|
||||||
|
},
|
||||||
|
onCompressionCompleted: (evt: RunEvent) => {
|
||||||
|
if (closed) return
|
||||||
|
onEvent(evt)
|
||||||
|
},
|
||||||
|
onUsageUpdated: (evt: RunEvent) => {
|
||||||
|
if (closed) return
|
||||||
|
onEvent(evt)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
function onRunStarted(data: RunEvent) {
|
// Register handlers in the global session map
|
||||||
handleEvent(data)
|
sessionEventHandlers.set(sid, handlers)
|
||||||
onStarted?.(data.run_id || '')
|
|
||||||
}
|
|
||||||
function onRunFailed(data: RunEvent) {
|
|
||||||
handleEvent(data)
|
|
||||||
onError?.(new Error(data.error || 'Run failed'))
|
|
||||||
}
|
|
||||||
function onMessageDelta(data: RunEvent) { handleEvent(data) }
|
|
||||||
function onReasoningDelta(data: RunEvent) { handleEvent(data) }
|
|
||||||
function onThinkingDelta(data: RunEvent) { handleEvent(data) }
|
|
||||||
function onReasoningAvailable(data: RunEvent) { handleEvent(data) }
|
|
||||||
function onToolStarted(data: RunEvent) { handleEvent(data) }
|
|
||||||
function onToolCompleted(data: RunEvent) { handleEvent(data) }
|
|
||||||
function onRunCompleted(data: RunEvent) { handleEvent(data) }
|
|
||||||
function onCompressionStarted(data: RunEvent) { handleEvent(data) }
|
|
||||||
function onCompressionCompleted(data: RunEvent) { handleEvent(data) }
|
|
||||||
function onUsageUpdated(data: RunEvent) { handleEvent(data) }
|
|
||||||
|
|
||||||
socket.on('run.started', onRunStarted)
|
// Emit run request
|
||||||
socket.on('run.failed', onRunFailed)
|
const socket = connectChatRun()
|
||||||
socket.on('message.delta', onMessageDelta)
|
|
||||||
socket.on('reasoning.delta', onReasoningDelta)
|
|
||||||
socket.on('thinking.delta', onThinkingDelta)
|
|
||||||
socket.on('reasoning.available', onReasoningAvailable)
|
|
||||||
socket.on('tool.started', onToolStarted)
|
|
||||||
socket.on('tool.completed', onToolCompleted)
|
|
||||||
socket.on('run.completed', onRunCompleted)
|
|
||||||
socket.on('compression.started', onCompressionStarted)
|
|
||||||
socket.on('compression.completed', onCompressionCompleted)
|
|
||||||
socket.on('usage.updated', onUsageUpdated)
|
|
||||||
|
|
||||||
// Emit run:start with ack callback to get run_id
|
|
||||||
socket.emit('run', body)
|
socket.emit('run', body)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
abort: () => {
|
abort: () => {
|
||||||
if (!closed) {
|
if (!closed) {
|
||||||
socket.emit('abort', { session_id: body.session_id })
|
closed = true
|
||||||
cleanup()
|
sessionEventHandlers.delete(sid)
|
||||||
|
socket.emit('abort', { session_id: sid })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { startRunViaSocket, connectChatRun, resumeSession, type RunEvent } from '@/api/hermes/chat'
|
import { startRunViaSocket, resumeSession, registerSessionHandlers, unregisterSessionHandlers, type RunEvent } from '@/api/hermes/chat'
|
||||||
import { deleteSession as deleteSessionApi, fetchSession, fetchSessions, type HermesMessage, type SessionSummary } from '@/api/hermes/sessions'
|
import { deleteSession as deleteSessionApi, fetchSession, fetchSessions, type HermesMessage, type SessionSummary } from '@/api/hermes/sessions'
|
||||||
import { getApiKey } from '@/api/client'
|
import { getApiKey } from '@/api/client'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
@@ -582,6 +582,8 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
addMessage(sid, userMsg)
|
addMessage(sid, userMsg)
|
||||||
|
|
||||||
|
|
||||||
updateSessionTitle(sid)
|
updateSessionTitle(sid)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -855,6 +857,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanup()
|
cleanup()
|
||||||
updateSessionTitle(sid)
|
updateSessionTitle(sid)
|
||||||
// the in-flight marker. If the browser is reloading right now
|
// the in-flight marker. If the browser is reloading right now
|
||||||
@@ -962,7 +965,6 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
// Only set up listeners if there's an actual in-flight run
|
// Only set up listeners if there's an actual in-flight run
|
||||||
if (!readInFlight(sid)) return
|
if (!readInFlight(sid)) return
|
||||||
|
|
||||||
const socket = connectChatRun()
|
|
||||||
let closed = false
|
let closed = false
|
||||||
let runProducedAssistantText = false
|
let runProducedAssistantText = false
|
||||||
let runHadToolActivity = false
|
let runHadToolActivity = false
|
||||||
@@ -970,19 +972,10 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
if (closed) return
|
if (closed) return
|
||||||
closed = true
|
closed = true
|
||||||
socket.off('run.started', onRunStarted)
|
|
||||||
socket.off('run.failed', onRunFailed)
|
|
||||||
socket.off('message.delta', onMessageDelta)
|
|
||||||
socket.off('reasoning.delta', onReasoningDelta)
|
|
||||||
socket.off('thinking.delta', onThinkingDelta)
|
|
||||||
socket.off('reasoning.available', onReasoningAvailable)
|
|
||||||
socket.off('tool.started', onToolStarted)
|
|
||||||
socket.off('tool.completed', onToolCompleted)
|
|
||||||
socket.off('run.completed', onRunCompleted)
|
|
||||||
socket.off('compression.started', onCompressionStarted)
|
|
||||||
socket.off('compression.completed', onCompressionCompleted)
|
|
||||||
streamStates.value.delete(sid)
|
streamStates.value.delete(sid)
|
||||||
serverWorking.value.delete(sid)
|
serverWorking.value.delete(sid)
|
||||||
|
// Unregister from global session handlers
|
||||||
|
unregisterSessionHandlers(sid)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared event handler — filters by session_id tag
|
// Shared event handler — filters by session_id tag
|
||||||
@@ -1172,6 +1165,8 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
cleanup()
|
cleanup()
|
||||||
updateSessionTitle(sid)
|
updateSessionTitle(sid)
|
||||||
|
|
||||||
@@ -1218,33 +1213,25 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onRunStarted(data: RunEvent) { handleEvent(data) }
|
// Register handlers in global session map
|
||||||
function onRunFailed(data: RunEvent) { handleEvent(data) }
|
registerSessionHandlers(sid, {
|
||||||
function onMessageDelta(data: RunEvent) { handleEvent(data) }
|
onMessageDelta: (evt) => handleEvent(evt),
|
||||||
function onReasoningDelta(data: RunEvent) { handleEvent(data) }
|
onReasoningDelta: (evt) => handleEvent(evt),
|
||||||
function onThinkingDelta(data: RunEvent) { handleEvent(data) }
|
onThinkingDelta: (evt) => handleEvent(evt),
|
||||||
function onReasoningAvailable(data: RunEvent) { handleEvent(data) }
|
onReasoningAvailable: (evt) => handleEvent(evt),
|
||||||
function onToolStarted(data: RunEvent) { handleEvent(data) }
|
onToolStarted: (evt) => handleEvent(evt),
|
||||||
function onToolCompleted(data: RunEvent) { handleEvent(data) }
|
onToolCompleted: (evt) => handleEvent(evt),
|
||||||
function onRunCompleted(data: RunEvent) { handleEvent(data) }
|
onRunStarted: (evt) => handleEvent(evt),
|
||||||
function onCompressionStarted(data: RunEvent) { handleEvent(data) }
|
onRunCompleted: (evt) => handleEvent(evt),
|
||||||
function onCompressionCompleted(data: RunEvent) { handleEvent(data) }
|
onRunFailed: (evt) => handleEvent(evt),
|
||||||
|
onCompressionStarted: (evt) => handleEvent(evt),
|
||||||
socket.on('run.started', onRunStarted)
|
onCompressionCompleted: (evt) => handleEvent(evt),
|
||||||
socket.on('run.failed', onRunFailed)
|
onUsageUpdated: (evt) => handleEvent(evt),
|
||||||
socket.on('message.delta', onMessageDelta)
|
})
|
||||||
socket.on('reasoning.delta', onReasoningDelta)
|
|
||||||
socket.on('thinking.delta', onThinkingDelta)
|
|
||||||
socket.on('reasoning.available', onReasoningAvailable)
|
|
||||||
socket.on('tool.started', onToolStarted)
|
|
||||||
socket.on('tool.completed', onToolCompleted)
|
|
||||||
socket.on('run.completed', onRunCompleted)
|
|
||||||
socket.on('compression.started', onCompressionStarted)
|
|
||||||
socket.on('compression.completed', onCompressionCompleted)
|
|
||||||
|
|
||||||
// No need to emit resume here — switchSession already did it.
|
// No need to emit resume here — switchSession already did it.
|
||||||
// Server already joined room and replayed events.
|
// Server already joined room and replayed events.
|
||||||
// Just set up listeners for ongoing streaming events.
|
// Just set up handlers for ongoing streaming events.
|
||||||
|
|
||||||
// Mark as streaming so UI shows the indicator
|
// Mark as streaming so UI shows the indicator
|
||||||
streamStates.value.set(sid, { abort: cleanup })
|
streamStates.value.set(sid, { abort: cleanup })
|
||||||
|
|||||||
@@ -421,6 +421,7 @@ export function updateSessionStats(id: string): void {
|
|||||||
last_active = COALESCE((SELECT MAX(timestamp) FROM ${MESSAGES_TABLE} WHERE session_id = ?), started_at)
|
last_active = COALESCE((SELECT MAX(timestamp) FROM ${MESSAGES_TABLE} WHERE session_id = ?), started_at)
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
).run(id, id, id)
|
).run(id, id, id)
|
||||||
|
console.log(`Updated session ${id} stats`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSessionDetailPaginated(
|
export function getSessionDetailPaginated(
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ function convertToAnthropicFormat(messages: any[]): any[] {
|
|||||||
// Regular user message
|
// Regular user message
|
||||||
if (role === 'user') {
|
if (role === 'user') {
|
||||||
if (typeof content === 'string') {
|
if (typeof content === 'string') {
|
||||||
result.push({ role: 'user', content: content || '(empty message)' })
|
result.push({ role: 'user', content: content || '' })
|
||||||
} else if (Array.isArray(content)) {
|
} else if (Array.isArray(content)) {
|
||||||
result.push({ role: 'user', content })
|
result.push({ role: 'user', content })
|
||||||
}
|
}
|
||||||
@@ -129,6 +129,7 @@ interface SessionMessage {
|
|||||||
session_id: string
|
session_id: string
|
||||||
role: string
|
role: string
|
||||||
content: string
|
content: string
|
||||||
|
hermesSessionId?: string
|
||||||
tool_call_id?: string | null
|
tool_call_id?: string | null
|
||||||
tool_calls?: any[] | null
|
tool_calls?: any[] | null
|
||||||
tool_name?: string | null
|
tool_name?: string | null
|
||||||
@@ -147,8 +148,6 @@ interface SessionState {
|
|||||||
events: Array<{ event: string; data: any }>
|
events: Array<{ event: string; data: any }>
|
||||||
abortController?: AbortController
|
abortController?: AbortController
|
||||||
runId?: string
|
runId?: string
|
||||||
/** Ephemeral session ID used for Hermes (one per run) */
|
|
||||||
hermesSessionId?: string
|
|
||||||
profile?: string
|
profile?: string
|
||||||
inputTokens?: number
|
inputTokens?: number
|
||||||
outputTokens?: number
|
outputTokens?: number
|
||||||
@@ -161,6 +160,7 @@ export class ChatRunSocket {
|
|||||||
private gatewayManager: any
|
private gatewayManager: any
|
||||||
/** sessionId → session state (messages, working status, events, run tracking) */
|
/** sessionId → session state (messages, working status, events, run tracking) */
|
||||||
private sessionMap = new Map<string, SessionState>()
|
private sessionMap = new Map<string, SessionState>()
|
||||||
|
private hermesSessionIds = new Map<string, any>()
|
||||||
|
|
||||||
constructor(io: Server, gatewayManager: any) {
|
constructor(io: Server, gatewayManager: any) {
|
||||||
this.nsp = io.of('/chat-run')
|
this.nsp = io.of('/chat-run')
|
||||||
@@ -215,15 +215,10 @@ export class ChatRunSocket {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
private async resumeSession(socket: Socket, sid: string) {
|
private handleMessage(messages: SessionMessage[], sid: string): any[] {
|
||||||
let state = this.sessionMap.get(sid)
|
let _messages = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const detail = useLocalSessionStore()
|
_messages = messages
|
||||||
? getSessionDetailPaginated(sid)
|
|
||||||
: await getSessionDetailFromDb(sid)
|
|
||||||
const messages = detail?.messages?.length
|
|
||||||
? detail.messages
|
|
||||||
.filter(m => (m.role === 'user' || m.role === 'assistant' || m.role === 'tool') && m.content !== undefined)
|
.filter(m => (m.role === 'user' || m.role === 'assistant' || m.role === 'tool') && m.content !== undefined)
|
||||||
.map((m, idx, arr) => {
|
.map((m, idx, arr) => {
|
||||||
const msg: any = {
|
const msg: any = {
|
||||||
@@ -361,7 +356,19 @@ export class ChatRunSocket {
|
|||||||
return msg
|
return msg
|
||||||
})
|
})
|
||||||
.filter(m => m !== null)
|
.filter(m => m !== null)
|
||||||
: []
|
} catch (error) {
|
||||||
|
|
||||||
|
}
|
||||||
|
return _messages
|
||||||
|
}
|
||||||
|
private async resumeSession(socket: Socket, sid: string) {
|
||||||
|
let state = this.sessionMap.get(sid)
|
||||||
|
if (!state) {
|
||||||
|
try {
|
||||||
|
const detail = useLocalSessionStore()
|
||||||
|
? getSessionDetailPaginated(sid)
|
||||||
|
: await getSessionDetailFromDb(sid)
|
||||||
|
const messages = detail?.messages ? this.handleMessage(detail.messages, sid) : []
|
||||||
// Calculate context tokens — aware of compression snapshot
|
// Calculate context tokens — aware of compression snapshot
|
||||||
let inputTokens: number
|
let inputTokens: number
|
||||||
const snapshot = getCompressionSnapshot(sid)
|
const snapshot = getCompressionSnapshot(sid)
|
||||||
@@ -389,105 +396,10 @@ export class ChatRunSocket {
|
|||||||
state = { messages: [], isWorking: false, events: [] }
|
state = { messages: [], isWorking: false, events: [] }
|
||||||
this.sessionMap.set(sid, state)
|
this.sessionMap.set(sid, state)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reply with messages, working status + events (if working)
|
|
||||||
// Convert messages from internal storage format to OpenAI format for client
|
|
||||||
const clientMessages = state.messages.map((m: any) => {
|
|
||||||
const msg: any = { ...m }
|
|
||||||
// Check if content is a stringified array (Hermes Gateway behavior) - only for assistant messages
|
|
||||||
if (m.role === 'assistant' && typeof m.content === 'string') {
|
|
||||||
// Handle double-serialized content: "[{'type': 'text', ...}]"
|
|
||||||
let contentToParse = m.content
|
|
||||||
const trimmed = m.content.trim()
|
|
||||||
if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) {
|
|
||||||
contentToParse = trimmed.slice(1, -1)
|
|
||||||
logger.info('[chat-run-socket] resume message %s: double-serialized, removed outer quotes', m.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contentToParse.trim().startsWith('[') && contentToParse.trim().endsWith(']')) {
|
|
||||||
try {
|
|
||||||
// Parse stringified Python-like array to JSON
|
|
||||||
const parsedContent = JSON.parse(
|
|
||||||
contentToParse
|
|
||||||
.replace(/'/g, '"')
|
|
||||||
.replace(/True/g, 'true')
|
|
||||||
.replace(/False/g, 'false')
|
|
||||||
.replace(/None/g, 'null')
|
|
||||||
)
|
|
||||||
if (Array.isArray(parsedContent)) {
|
|
||||||
const textBlocks: string[] = []
|
|
||||||
const toolCalls: any[] = []
|
|
||||||
let reasoningContent: string | null = null
|
|
||||||
|
|
||||||
for (const block of parsedContent) {
|
|
||||||
if (block.type === 'thinking') {
|
|
||||||
reasoningContent = block.thinking
|
|
||||||
} else if (block.type === 'text') {
|
|
||||||
textBlocks.push(block.text)
|
|
||||||
} else if (block.type === 'tool_use') {
|
|
||||||
toolCalls.push({
|
|
||||||
id: block.id,
|
|
||||||
type: 'function',
|
|
||||||
function: {
|
|
||||||
name: block.name,
|
|
||||||
arguments: JSON.stringify(block.input)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
msg.content = textBlocks.join('') || ''
|
|
||||||
if (toolCalls.length > 0) {
|
|
||||||
msg.tool_calls = toolCalls
|
|
||||||
}
|
|
||||||
if (reasoningContent) {
|
|
||||||
msg.reasoning = reasoningContent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('[chat-run-socket] resume message %s: failed to parse content, error=%s, content=%s', m.id, (e as Error).message, contentToParse.substring(0, 200))
|
|
||||||
// Parsing failed, keep original content
|
|
||||||
msg.content = m.content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (Array.isArray(m.content)) {
|
|
||||||
// If content is an array (Anthropic format), convert to OpenAI format
|
|
||||||
const textBlocks: string[] = []
|
|
||||||
const toolCalls: any[] = []
|
|
||||||
let reasoningContent: string | null = null
|
|
||||||
|
|
||||||
for (const block of m.content) {
|
|
||||||
if (block.type === 'thinking') {
|
|
||||||
reasoningContent = block.thinking
|
|
||||||
} else if (block.type === 'text') {
|
|
||||||
textBlocks.push(block.text)
|
|
||||||
} else if (block.type === 'tool_use') {
|
|
||||||
toolCalls.push({
|
|
||||||
id: block.id,
|
|
||||||
type: 'function',
|
|
||||||
function: {
|
|
||||||
name: block.name,
|
|
||||||
arguments: JSON.stringify(block.input)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
msg.content = textBlocks.join('') || ''
|
|
||||||
if (toolCalls.length > 0) {
|
|
||||||
msg.tool_calls = toolCalls
|
|
||||||
}
|
|
||||||
if (reasoningContent) {
|
|
||||||
msg.reasoning = reasoningContent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return msg
|
|
||||||
})
|
|
||||||
|
|
||||||
socket.emit('resumed', {
|
socket.emit('resumed', {
|
||||||
session_id: sid,
|
session_id: sid,
|
||||||
messages: clientMessages,
|
messages: state.messages,
|
||||||
isWorking: state.isWorking,
|
isWorking: state.isWorking,
|
||||||
events: state.isWorking ? state.events : [],
|
events: state.isWorking ? state.events : [],
|
||||||
inputTokens: state.inputTokens,
|
inputTokens: state.inputTokens,
|
||||||
@@ -518,8 +430,8 @@ export class ChatRunSocket {
|
|||||||
// Mark working immediately on run start, and append user message
|
// Mark working immediately on run start, and append user message
|
||||||
if (session_id) {
|
if (session_id) {
|
||||||
const state = this.getOrCreateSession(session_id)
|
const state = this.getOrCreateSession(session_id)
|
||||||
|
this.hermesSessionIds.set(session_id, hermesSessionId)
|
||||||
state.isWorking = true
|
state.isWorking = true
|
||||||
state.hermesSessionId = hermesSessionId
|
|
||||||
state.profile = profile
|
state.profile = profile
|
||||||
state.messages.push({
|
state.messages.push({
|
||||||
id: state.messages.length + 1,
|
id: state.messages.length + 1,
|
||||||
@@ -599,7 +511,7 @@ export class ChatRunSocket {
|
|||||||
? validMessages.slice(0, validMessages.length - lastUserMsgIndex - 1)
|
? validMessages.slice(0, validMessages.length - lastUserMsgIndex - 1)
|
||||||
: validMessages
|
: validMessages
|
||||||
).map((m, idx, arr) => {
|
).map((m, idx, arr) => {
|
||||||
const msg: any = { role: m.role, content: m.content || 'empty message' }
|
const msg: any = { role: m.role, content: m.content || '' }
|
||||||
if (m.reasoning_content) msg.reasoning_content = m.reasoning_content
|
if (m.reasoning_content) msg.reasoning_content = m.reasoning_content
|
||||||
if (m.tool_calls?.length) {
|
if (m.tool_calls?.length) {
|
||||||
// Filter out tool_calls with empty/invalid id and remove internal fields
|
// Filter out tool_calls with empty/invalid id and remove internal fields
|
||||||
@@ -960,6 +872,7 @@ export class ChatRunSocket {
|
|||||||
msgs.push({
|
msgs.push({
|
||||||
id: msgs.length + 1,
|
id: msgs.length + 1,
|
||||||
session_id,
|
session_id,
|
||||||
|
hermesSessionId,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: parsed.delta || '',
|
content: parsed.delta || '',
|
||||||
timestamp: Math.floor(Date.now() / 1000),
|
timestamp: Math.floor(Date.now() / 1000),
|
||||||
@@ -978,6 +891,7 @@ export class ChatRunSocket {
|
|||||||
id: msgs.length + 1,
|
id: msgs.length + 1,
|
||||||
session_id,
|
session_id,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
|
hermesSessionId,
|
||||||
content: '',
|
content: '',
|
||||||
reasoning: text,
|
reasoning: text,
|
||||||
timestamp: Math.floor(Date.now() / 1000),
|
timestamp: Math.floor(Date.now() / 1000),
|
||||||
@@ -993,6 +907,7 @@ export class ChatRunSocket {
|
|||||||
id: msgs.length + 1,
|
id: msgs.length + 1,
|
||||||
session_id,
|
session_id,
|
||||||
role: 'tool',
|
role: 'tool',
|
||||||
|
hermesSessionId,
|
||||||
content: '',
|
content: '',
|
||||||
tool_call_id: parsed.tool_call_id || null,
|
tool_call_id: parsed.tool_call_id || null,
|
||||||
tool_name: parsed.tool || parsed.name || null,
|
tool_name: parsed.tool || parsed.name || null,
|
||||||
@@ -1025,6 +940,7 @@ export class ChatRunSocket {
|
|||||||
msgs.push({
|
msgs.push({
|
||||||
id: msgs.length + 1,
|
id: msgs.length + 1,
|
||||||
session_id,
|
session_id,
|
||||||
|
hermesSessionId,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: parsed.output,
|
content: parsed.output,
|
||||||
timestamp: Math.floor(Date.now() / 1000),
|
timestamp: Math.floor(Date.now() / 1000),
|
||||||
@@ -1141,12 +1057,11 @@ export class ChatRunSocket {
|
|||||||
state.abortController = undefined
|
state.abortController = undefined
|
||||||
state.runId = undefined
|
state.runId = undefined
|
||||||
state.events = []
|
state.events = []
|
||||||
|
|
||||||
// Sync messages from Hermes ephemeral session to local DB
|
// Sync messages from Hermes ephemeral session to local DB
|
||||||
if (useLocalSessionStore() && state.hermesSessionId) {
|
if (useLocalSessionStore() && this.hermesSessionIds.get(sessionId)) {
|
||||||
const hermesId = state.hermesSessionId
|
const hermesId = this.hermesSessionIds.get(sessionId)
|
||||||
const prof = state.profile
|
const prof = state.profile
|
||||||
state.hermesSessionId = undefined
|
this.hermesSessionIds.delete(sessionId)
|
||||||
state.profile = undefined
|
state.profile = undefined
|
||||||
this.syncFromHermes(socket, sessionId, hermesId, prof)
|
this.syncFromHermes(socket, sessionId, hermesId, prof)
|
||||||
}
|
}
|
||||||
@@ -1207,7 +1122,6 @@ export class ChatRunSocket {
|
|||||||
logger.warn('[chat-run-socket] syncFromHermes: no data for Hermes session %s', hermesSessionId)
|
logger.warn('[chat-run-socket] syncFromHermes: no data for Hermes session %s', hermesSessionId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip user messages — already written to local DB in handleRun
|
// Skip user messages — already written to local DB in handleRun
|
||||||
const toInsert = detail.messages.filter(m => m.role !== 'user')
|
const toInsert = detail.messages.filter(m => m.role !== 'user')
|
||||||
|
|
||||||
@@ -1301,6 +1215,10 @@ export class ChatRunSocket {
|
|||||||
// Use inputTokens already set by compression path if available
|
// Use inputTokens already set by compression path if available
|
||||||
const state = this.sessionMap.get(localSessionId)
|
const state = this.sessionMap.get(localSessionId)
|
||||||
if (state) {
|
if (state) {
|
||||||
|
const messages = this.handleMessage(toInsert, localSessionId)
|
||||||
|
if (messages.length > 0) {
|
||||||
|
this.replaceByHermesSessionId(localSessionId, hermesSessionId, messages)
|
||||||
|
}
|
||||||
const emit = (event: string, payload: any) => {
|
const emit = (event: string, payload: any) => {
|
||||||
socket.emit(event, { ...payload, session_id: localSessionId })
|
socket.emit(event, { ...payload, session_id: localSessionId })
|
||||||
}
|
}
|
||||||
@@ -1314,7 +1232,28 @@ export class ChatRunSocket {
|
|||||||
logger.warn(err, '[chat-run-socket] syncFromHermes failed for session %s (hermesId: %s, profile: %s)', localSessionId, hermesSessionId, profile || 'default')
|
logger.warn(err, '[chat-run-socket] syncFromHermes failed for session %s (hermesId: %s, profile: %s)', localSessionId, hermesSessionId, profile || 'default')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
private replaceByHermesSessionId(session_id: string, hermesSessionId: string, newItems: SessionMessage[]) {
|
||||||
|
let start = -1
|
||||||
|
let end = -1
|
||||||
|
const state = this.sessionMap.get(session_id)
|
||||||
|
const msg = state?.messages || []
|
||||||
|
// 找区间
|
||||||
|
for (let i = 0; i < msg.length; i++) {
|
||||||
|
if (msg[i].hermesSessionId === hermesSessionId) {
|
||||||
|
if (start === -1) start = i
|
||||||
|
end = i
|
||||||
|
} else if (start !== -1) {
|
||||||
|
// 已经找到一段,后面断了就可以结束
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没找到
|
||||||
|
if (start === -1) return
|
||||||
|
// 替换
|
||||||
|
msg.splice(start, end - start + 1, ...newItems)
|
||||||
|
console.log(msg)
|
||||||
|
}
|
||||||
/** Enqueue an ephemeral Hermes session for deferred deletion */
|
/** Enqueue an ephemeral Hermes session for deferred deletion */
|
||||||
private enqueueEphemeralDelete(hermesSessionId: string, profile?: string) {
|
private enqueueEphemeralDelete(hermesSessionId: string, profile?: string) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user