Files
Hermes-ui/packages/server/src/services/hermes/chat-run-socket.ts
T
ekkoandClaude Opus 4.7 fc02348ebd fix: prevent message loss on abort by deferring DB writes to flush (#591)
Defer all non-user message DB writes until response completion or
abort, instead of writing tool calls immediately during streaming.
This ensures correct message ordering and prevents the abort handler
from overwriting displayed messages with incomplete DB data.

- Remove immediate addMessage() calls from response.output_item.done
- Remove immediate addMessage() from insertResponseTextOnce
- Add flushResponseRunToDb() to batch-write all run messages on
  both normal completion (markCompleted) and abort (handleAbort)
- Skip user messages in flush (already written in handleRun)
- Remove refreshActiveSession() from abort.completed frontend handler

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 04:10:01 +08:00

1587 lines
59 KiB
TypeScript

/**
* Chat run via Socket.IO — namespace /chat-run.
*
* Replaces HTTP POST + SSE. Socket.IO decouples message handling
* from connection lifecycle: the server continues streaming upstream
* events even after the client disconnects or refreshes.
*
* Uses Socket.IO rooms keyed by session_id. On client reconnect,
* the client emits 'resume' to rejoin its session room.
*/
import type { Server, Socket } from 'socket.io'
import { getSystemPrompt } from '../../lib/llm-prompt'
import {
getSession,
getSessionDetail,
getSessionDetailPaginated,
createSession,
addMessage,
updateSessionStats,
useLocalSessionStore,
} from '../../db/hermes/session-store'
import { getSessionDetailFromDb } from '../../db/hermes/sessions-db'
import { getModelContextLength } from './model-context'
import { ChatContextCompressor, countTokens, SUMMARY_PREFIX } from '../../lib/context-compressor'
import { getCompressionSnapshot } from '../../db/hermes/compression-snapshot'
import { parseLLMJSON, parseToolArguments, parseAnthropicContentArray } from '../../lib/llm-json'
import { updateUsage } from '../../db/hermes/usage-store'
import { logger } from '../logger'
/**
* Content block types for Anthropic-compatible message format
*/
export type ContentBlock =
| { type: 'text'; text: string }
| { type: 'image'; name: string; path: string; media_type: string }
| { type: 'file'; name: string; path: string; media_type?: string }
/**
* Convert ContentBlock[] to string for display/storage
* - string → 直接返回
* - ContentBlock[] → 返回 JSON 字符串
*/
function contentBlocksToString(input: string | ContentBlock[]): string {
if (typeof input === 'string') return input
return JSON.stringify(input)
}
/**
* Extract text content from ContentBlock[] for title preview
*/
function extractTextForPreview(input: string | ContentBlock[]): string {
if (typeof input === 'string') return input
return input
.filter(block => block.type === 'text')
.map(block => block.text)
.join('\n')
}
/**
* Check if input is ContentBlock array
*/
function isContentBlockArray(input: any): input is ContentBlock[] {
return Array.isArray(input) && input.length > 0 && ('type' in input[0])
}
/**
* Convert file/image blocks with path to base64 format for upstream API
*
* Converts images to base64 data URLs for Anthropic/OpenAI API compatibility.
* File attachments are converted to text mentions.
*/
async function convertContentBlocks(blocks: ContentBlock[]): Promise<string> {
let contentStr = ''
for (const block of blocks) {
if (block.type === 'text') {
contentStr += block.text
} else if (block.type === 'image') {
contentStr += `[Image: ${block.path}]`
} else if (block.type === 'file') {
contentStr += `[File: ${block.path}]`
}
}
return contentStr
}
const compressor = new ChatContextCompressor()
// --- Helper: Convert OpenAI format to Anthropic format ---
function convertHistoryFormat(messages: any[]): any[] {
const result: any[] = []
for (const m of messages) {
const role = m.role
const content = m.content || ''
delete m.reasoning_content
if (role === 'tool') {
// Convert tool message to tool_result in user message
// Follow Hermes official format: content is a string (not array)
let pushItem = { ...m }
pushItem.role = 'user'
pushItem.content = `[Tool result: ${content}]`
result.push(pushItem)
continue
}
// Regular user message
if (role === 'user') {
// Format: { role: 'user', content: [{ type: 'text', text: '...' }] }
if (typeof content === 'string') {
result.push({ role: 'user', content: content })
} else if (Array.isArray(content)) {
// Already in array format, assume it's correct
result.push({ role: 'user', content: convertContentBlocks(content) })
}
continue
}
if (role === 'assistant') {
result.push({ ...m })
continue
}
}
return result
}
// --- Session state tracking ---
interface SessionMessage {
id: number | string
session_id: string
role: string
content: string
runMarker?: string
tool_call_id?: string | null
tool_calls?: any[] | null
tool_name?: string | null
timestamp: number
token_count?: number | null
finish_reason?: string | null
reasoning?: string | null
reasoning_details?: string | null
reasoning_content?: string | null
}
interface QueuedRun {
queue_id: string
input: string | ContentBlock[]
model?: string
instructions?: string
profile: string
}
interface SessionState {
messages: SessionMessage[]
isWorking: boolean
events: Array<{ event: string; data: any }>
abortController?: AbortController
runId?: string
profile?: string
inputTokens?: number
outputTokens?: number
isAborting?: boolean
queue: QueuedRun[]
responseRun?: ResponseRunState
}
interface ResponseRunState {
runMarker?: string
responseId?: string
text: string
textInserted: boolean
insertedKeys: Set<string>
toolCalls: Map<string, any>
}
// --- ChatRunSocket ---
export class ChatRunSocket {
private nsp: ReturnType<Server['of']>
private gatewayManager: any
/** sessionId → session state (messages, working status, events, run tracking) */
private sessionMap = new Map<string, SessionState>()
constructor(io: Server, gatewayManager: any) {
this.nsp = io.of('/chat-run')
this.gatewayManager = gatewayManager
}
init() {
this.nsp.use(this.authMiddleware.bind(this))
this.nsp.on('connection', this.onConnection.bind(this))
logger.info('[chat-run-socket] Socket.IO ready at /chat-run')
}
// --- Auth middleware ---
private async authMiddleware(socket: Socket, next: (err?: Error) => void) {
const token = socket.handshake.auth?.token as string | undefined
if (!process.env.AUTH_DISABLED && process.env.AUTH_DISABLED !== '1') {
const { getToken } = await import('../auth')
const serverToken = await getToken()
if (serverToken && token !== serverToken) {
return next(new Error('Authentication failed'))
}
}
next()
}
// --- Connection handler ---
private onConnection(socket: Socket) {
const profile = (socket.handshake.query?.profile as string) || 'default'
socket.on('run', async (data: {
input: string | ContentBlock[]
session_id?: string
model?: string
instructions?: string
queue_id?: string
}) => {
if (data.session_id) {
const state = this.getOrCreateSession(data.session_id)
if (state.isWorking) {
state.queue.push({
queue_id: data.queue_id || `queue_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
input: data.input,
model: data.model,
instructions: data.instructions,
profile,
})
this.nsp.to(`session:${data.session_id}`).emit('run.queued', {
event: 'run.queued',
session_id: data.session_id,
queue_length: state.queue.length,
})
logger.info('[chat-run-socket] queued run for session %s (queue: %d)', data.session_id, state.queue.length)
return
}
}
await this.handleRun(socket, data, profile)
})
socket.on('cancel_queued_run', (data: { session_id?: string; queue_id?: string }) => {
if (!data.session_id || !data.queue_id) return
const state = this.sessionMap.get(data.session_id)
if (!state?.queue.length) return
const before = state.queue.length
state.queue = state.queue.filter(item => item.queue_id !== data.queue_id)
if (state.queue.length === before) return
this.nsp.to(`session:${data.session_id}`).emit('run.queued', {
event: 'run.queued',
session_id: data.session_id,
queue_length: state.queue.length,
})
logger.info('[chat-run-socket] cancelled queued run %s for session %s (queue: %d)',
data.queue_id, data.session_id, state.queue.length)
})
socket.on('resume', async (data: { session_id?: string }) => {
if (!data.session_id) return
const sid = data.session_id
const room = `session:${sid}`
socket.join(room)
this.resumeSession(socket, sid)
})
socket.on('abort', (data: { session_id?: string }) => {
if (data.session_id) {
void this.handleAbort(socket, data.session_id)
}
})
}
private handleMessage(messages: SessionMessage[], sid: string): any[] {
let _messages = []
try {
_messages = messages
.filter(m => (m.role === 'user' || m.role === 'assistant' || m.role === 'tool') && m.content !== undefined)
.map((m, idx, arr) => {
const msg: any = {
id: m.id,
session_id: sid,
role: m.role,
content: m.content || '',
reasoning: m.reasoning || '',
timestamp: m.timestamp,
}
// Convert Anthropic format content to OpenAI format
// 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', ...}]" -> "[{'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.startsWith('[') && contentToParse.endsWith(']')) {
try {
// Use robust LLM JSON parser
const parsedContent = parseAnthropicContentArray(contentToParse)
const textBlocks: string[] = []
const toolCalls: any[] = []
let reasoningContent: string | null = null
for (const block of parsedContent) {
if (block.type === 'thinking') {
reasoningContent = block.thinking || null
} 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: typeof block.input === 'object' ? JSON.stringify(block.input) : (block.input ?? '{}')
}
})
}
}
msg.content = textBlocks.join('') || ''
if (toolCalls.length > 0) {
msg.tool_calls = toolCalls
}
if (reasoningContent) {
msg.reasoning = reasoningContent
}
} catch (e) {
logger.warn(e, '[chat-run-socket] failed to parse array content for message %s, keeping original', m.id)
// Parsing failed, keep original content
msg.content = m.content
}
}
} else if (Array.isArray(m.content)) {
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
}
}
if (m.tool_calls?.length) {
// Filter out tool_calls with empty/invalid id and remove internal fields
const cleanedToolCalls = m.tool_calls
.filter((tc: any) => tc.id && tc.id.length > 0)
.map((tc: any) => ({
id: tc.id,
type: tc.type,
function: tc.function
}))
if (cleanedToolCalls.length > 0) {
msg.tool_calls = cleanedToolCalls
}
}
// For tool messages, ensure tool_call_id exists
if (m.role === 'tool') {
let callId = m.tool_call_id
if (!callId || callId.length === 0) {
// Try to reconstruct tool_call_id from previous assistant message
const prevMsg = arr[idx - 1]
if (prevMsg?.role === 'assistant' && prevMsg.tool_calls?.length) {
// Find matching tool_call by tool_name
const tc = prevMsg.tool_calls.find((t: any) => t.function?.name === m.tool_name)
if (tc?.id) {
callId = tc.id
}
}
}
// Skip tool message if no valid tool_call_id
if (!callId || callId.length === 0) {
return null
}
msg.tool_call_id = callId
}
if (m.tool_name) msg.tool_name = m.tool_name
if (m.reasoning) msg.reasoning = m.reasoning
return msg
})
.filter(m => m !== null)
} catch (error) {
}
return _messages
}
private async resumeSession(socket: Socket, sid: string) {
let state = this.sessionMap.get(sid)
if (!state) {
state = await this.loadSessionStateFromDb(sid)
this.sessionMap.set(sid, state)
}
socket.emit('resumed', {
session_id: sid,
messages: state.messages,
isWorking: state.isWorking,
isAborting: state.isAborting || false,
events: state.isWorking ? state.events : [],
inputTokens: state.inputTokens,
outputTokens: state.outputTokens,
queueLength: state.queue?.length || 0,
})
logger.info('[chat-run-socket] socket %s resumed session %s (working: %s, messages: %d)',
socket.id, sid, state.isWorking, state.messages.length)
}
private async loadSessionStateFromDb(sid: string): Promise<SessionState> {
try {
const detail = useLocalSessionStore()
? getSessionDetailPaginated(sid)
: await getSessionDetailFromDb(sid)
const messages = detail?.messages ? this.handleMessage(detail.messages, sid) : []
let inputTokens: number
let outputTokens: number
const snapshot = getCompressionSnapshot(sid)
if (snapshot) {
const newMessages = messages.slice(snapshot.lastMessageIndex + 1)
inputTokens = countTokens(SUMMARY_PREFIX + snapshot.summary) +
newMessages.filter(m => m.role === 'user').reduce((sum, m) => sum + countTokens(m.content || ''), 0)
outputTokens = newMessages
.filter(m => m.role === 'assistant' || m.role === 'tool')
.reduce((sum, m) => sum + countTokens(m.content || '') + countTokens(m.tool_calls + '' || ''), 0)
} else {
inputTokens = messages.filter(m => m.role === 'user').reduce((sum, m) => sum + countTokens(m.content || ''), 0)
outputTokens = messages
.filter(m => m.role === 'assistant' || m.role === 'tool')
.reduce((sum, m) => sum + countTokens(m.content || '') + countTokens(m.tool_calls + '' || ''), 0)
}
logger.info('[chat-run-socket] loaded session %s from DB (%d messages)', sid, messages.length)
return {
messages,
isWorking: false,
events: [],
inputTokens,
outputTokens,
queue: [],
}
} catch (err) {
logger.warn(err, '[chat-run-socket] failed to load session %s from DB', sid)
return { messages: [], isWorking: false, events: [], queue: [] }
}
}
// --- Run handler ---
private async handleRun(
socket: Socket,
data: { input: string | ContentBlock[]; session_id?: string; model?: string; instructions?: string },
profile: string,
skipUserMessage = false,
) {
const { input, session_id, model, instructions } = data
const upstream = this.gatewayManager.getUpstream(profile).replace(/\/$/, '')
const apiKey = this.gatewayManager.getApiKey(profile) || undefined
// Local marker used only to group in-memory messages for this streamed response.
const runMarker = session_id
? `resp_run_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
: undefined
const now = Math.floor(Date.now() / 1000)
// Mark working immediately on run start, and append user message
if (session_id) {
let state = this.sessionMap.get(session_id)
if (!state) {
state = getSession(session_id)
? await this.loadSessionStateFromDb(session_id)
: { messages: [], isWorking: false, events: [], queue: [] }
this.sessionMap.set(session_id, state)
}
state.isWorking = true
state.profile = profile
if (!skipUserMessage) {
// Convert ContentBlock[] to string for storage
const inputStr = contentBlocksToString(input)
state.messages.push({
id: state.messages.length + 1,
session_id,
runMarker,
role: 'user',
content: inputStr,
timestamp: now,
})
// Create session in local DB if it doesn't exist
if (!getSession(session_id)) {
const previewText = extractTextForPreview(input)
const preview = previewText.replace(/[\r\n]/g, ' ').substring(0, 100)
createSession({ id: session_id, profile, model, title: preview })
}
// Write user message to local DB immediately
addMessage({
session_id,
role: 'user',
content: inputStr,
timestamp: now,
})
} else {
// Dequeued: write the user message into both memory and DB so the
// backend transcript keeps the same run boundary as the client.
const inputStr = contentBlocksToString(input)
state.messages.push({
id: state.messages.length + 1,
session_id,
runMarker,
role: 'user',
content: inputStr,
timestamp: now,
})
if (!getSession(session_id)) {
const previewText = extractTextForPreview(input)
const preview = previewText.replace(/[\r\n]/g, ' ').substring(0, 100)
createSession({ id: session_id, profile, model, title: preview })
}
addMessage({
session_id,
role: 'user',
content: inputStr,
timestamp: now,
})
}
socket.join(`session:${session_id}`)
}
// Emit helper: tag every payload with session_id
const emit = (event: string, payload: any) => {
const tagged = session_id ? { ...payload, session_id } : payload
if (session_id) {
this.nsp.to(`session:${session_id}`).emit(event, tagged)
} else if (socket.connected) {
socket.emit(event, tagged)
}
}
try {
// Build upstream request body
const body: Record<string, any> = { input }
if (model) body.model = model
if (instructions) {
body.instructions = `${getSystemPrompt()}\n${instructions}`
} else {
body.instructions = getSystemPrompt()
}
// Inject workspace context if set for this session
if (session_id) {
const sessionRow = getSession(session_id)
if (sessionRow?.workspace) {
const workspaceCtx = `[Current working directory: ${sessionRow.workspace}]`
body.instructions = body.instructions
? `\n${workspaceCtx}\n${body.instructions}`
: `\n${workspaceCtx}`
}
}
// Build conversation_history from DB if session_id is provided
if (session_id) {
try {
const detail = useLocalSessionStore()
? getSessionDetail(session_id)
: await getSessionDetailFromDb(session_id)
if (detail?.messages?.length) {
// Filter valid messages
const validMessages = detail.messages.filter(m =>
(m.role === 'user' || m.role === 'assistant' || m.role === 'tool') && m.content !== undefined
)
// Exclude the last user message (just added in handleRun)
const lastUserMsgIndex = [...validMessages].reverse().findIndex(m => m.role === 'user')
let history: Array<{
role: string
content: string
tool_calls?: any[]
tool_call_id?: string
name?: string
reasoning_content?: string | null
}> = (lastUserMsgIndex >= 0
? validMessages.slice(0, validMessages.length - lastUserMsgIndex - 1)
: validMessages
).map((m, idx, arr) => {
const msg: any = { role: m.role, content: m.content || '' }
if (m.reasoning_content) msg.reasoning_content = m.reasoning_content
if (m.tool_calls?.length) {
// Filter out tool_calls with empty/invalid id and remove internal fields
const cleanedToolCalls = m.tool_calls
.filter((tc: any) => tc.id && tc.id.length > 0)
.map((tc: any) => ({
id: tc.id,
type: tc.type,
function: tc.function
}))
if (cleanedToolCalls.length > 0) {
msg.tool_calls = cleanedToolCalls
}
}
// For tool messages, ensure tool_call_id exists
if (m.role === 'tool') {
let callId = m.tool_call_id
if (!callId || callId.length === 0) {
// Try to reconstruct tool_call_id from previous assistant message
const prevMsg = arr[idx - 1]
if (prevMsg?.role === 'assistant' && prevMsg.tool_calls?.length) {
const tc = prevMsg.tool_calls.find((t: any) => t.function?.name === m.tool_name)
if (tc?.id) {
callId = tc.id
}
}
}
// Skip tool message if no valid tool_call_id
if (!callId || callId.length === 0) {
return null
}
msg.tool_call_id = callId
}
if (m.tool_name) msg.name = m.tool_name
return msg
})
.filter(m => m !== null)
// Context compression with snapshot awareness
const contextLength = getModelContextLength(profile)
const triggerTokens = Math.floor(contextLength / 2)
const cState = this.getOrCreateSession(session_id)
// Calculate inputTokens + outputTokens from DB (unified method)
const assembledTokens = await this.calcAndUpdateUsage(session_id, cState, emit)
const totalTokens = assembledTokens.inputTokens + assembledTokens.outputTokens
// Step 1: Check existing snapshot — if present, assemble summary + new messages
const snapshot = session_id ? getCompressionSnapshot(session_id) : null
if (snapshot) {
const newMessages = history.slice(snapshot.lastMessageIndex + 1)
logger.info('[context-compress] session=%s: snapshot at %d, %d new messages, assembled ~%d tokens (threshold %d)',
session_id, snapshot.lastMessageIndex, newMessages.length, totalTokens, triggerTokens)
// triggerTokens
if (totalTokens <= triggerTokens && newMessages.length <= 200) {
// Under threshold — use assembled context directly, no LLM call needed
history = [
{ role: 'user', content: SUMMARY_PREFIX + '\n\n' + snapshot.summary },
...newMessages,
]
} else {
this.pushState(session_id, 'compression.started', {
event: 'compression.started',
message_count: newMessages.length,
token_count: totalTokens,
})
emit('compression.started', {
event: 'compression.started',
message_count: newMessages.length,
token_count: totalTokens,
})
try {
const result = await compressor.compress(
history, upstream, apiKey, session_id,
)
const afterTokens = await this.calcAndUpdateUsage(session_id, cState, emit)
this.replaceState(session_id, 'compression.completed', {
event: 'compression.completed',
compressed: result.meta.compressed,
llmCompressed: result.meta.llmCompressed,
totalMessages: result.meta.totalMessages,
resultMessages: result.messages.length,
beforeTokens: totalTokens,
afterTokens: afterTokens.inputTokens + afterTokens.outputTokens,
summaryTokens: result.meta.summaryTokenEstimate,
verbatimCount: result.meta.verbatimCount,
compressedStartIndex: result.meta.compressedStartIndex,
})
logger.info('[context-compress] AFTER session=%s: %d messages, ~%d tokens (was %d)', session_id, result.messages.length, afterTokens.inputTokens + afterTokens.outputTokens, totalTokens)
emit('compression.completed', {
event: 'compression.completed',
compressed: result.meta.compressed,
llmCompressed: result.meta.llmCompressed,
totalMessages: result.meta.totalMessages,
resultMessages: result.messages.length,
beforeTokens: totalTokens,
afterTokens: afterTokens.inputTokens + afterTokens.outputTokens,
summaryTokens: result.meta.summaryTokenEstimate,
verbatimCount: result.meta.verbatimCount,
compressedStartIndex: result.meta.compressedStartIndex,
})
history = result.messages.map(m => {
const msg: any = {
role: m.role,
content: m.content,
tool_call_id: m.tool_call_id,
name: m.name,
}
if (m.reasoning_content) msg.reasoning_content = m.reasoning_content
// Filter tool_calls if present, remove internal fields
if (m.tool_calls?.length) {
const cleanedToolCalls = m.tool_calls
.filter((tc: any) => tc.id && tc.id.length > 0)
.map((tc: any) => ({
id: tc.id,
type: tc.type,
function: tc.function
}))
if (cleanedToolCalls.length > 0) {
msg.tool_calls = cleanedToolCalls
}
}
return msg
})
// Update usage from DB (snapshot now updated by compressor)
await this.calcAndUpdateUsage(session_id, cState, emit)
} catch (err: any) {
this.replaceState(session_id, 'compression.completed', {
event: 'compression.completed',
compressed: false,
totalMessages: newMessages.length,
resultMessages: newMessages.length,
beforeTokens: totalTokens,
afterTokens: totalTokens,
summaryTokens: 0,
verbatimCount: newMessages.length,
compressedStartIndex: -1,
error: err.message,
})
logger.warn(err, '[chat-run-socket] compression failed for session %s, using assembled context', session_id)
emit('compression.completed', {
event: 'compression.completed',
compressed: false,
totalMessages: newMessages.length,
resultMessages: newMessages.length,
beforeTokens: totalTokens,
afterTokens: totalTokens,
summaryTokens: 0,
verbatimCount: newMessages.length,
compressedStartIndex: -1,
error: err.message,
})
}
}
} else if (history.length > 4) {
// No snapshot — check if raw history exceeds threshold
if (totalTokens <= triggerTokens && history.length <= 200) {
// Under threshold — use raw history as-is
logger.info('[context-compress] session=%s: %d messages, ~%d tokens — under threshold, skip', session_id, history.length, totalTokens)
} else {
// Over threshold — full LLM compression
logger.info('[context-compress] BEFORE session=%s: %d messages, ~%d tokens (threshold %d)', session_id, history.length, totalTokens, triggerTokens)
this.pushState(session_id, 'compression.started', {
event: 'compression.started',
message_count: history.length,
token_count: totalTokens,
})
emit('compression.started', {
event: 'compression.started',
message_count: history.length,
token_count: totalTokens,
})
try {
const result = await compressor.compress(
history, upstream, apiKey, session_id,
)
const cState = this.getOrCreateSession(session_id)
const afterTokens = await this.calcAndUpdateUsage(session_id, cState, emit)
this.replaceState(session_id, 'compression.completed', {
event: 'compression.completed',
compressed: result.meta.compressed,
llmCompressed: result.meta.llmCompressed,
totalMessages: result.meta.totalMessages,
resultMessages: result.messages.length,
beforeTokens: totalTokens,
afterTokens: afterTokens.inputTokens + afterTokens.outputTokens,
summaryTokens: result.meta.summaryTokenEstimate,
verbatimCount: result.meta.verbatimCount,
compressedStartIndex: result.meta.compressedStartIndex,
})
logger.info('[context-compress] AFTER session=%s: %d messages, ~%d tokens (was %d)', session_id, result.messages.length, afterTokens.inputTokens + afterTokens.outputTokens, totalTokens)
emit('compression.completed', {
event: 'compression.completed',
compressed: result.meta.compressed,
llmCompressed: result.meta.llmCompressed,
totalMessages: result.meta.totalMessages,
resultMessages: result.messages.length,
beforeTokens: totalTokens,
afterTokens: afterTokens.inputTokens + afterTokens.outputTokens,
summaryTokens: result.meta.summaryTokenEstimate,
verbatimCount: result.meta.verbatimCount,
compressedStartIndex: result.meta.compressedStartIndex,
})
history = result.messages.map(m => {
const msg: any = {
role: m.role,
content: m.content,
tool_call_id: m.tool_call_id,
name: m.name,
}
if (m.reasoning_content) msg.reasoning_content = m.reasoning_content
// Filter tool_calls if present, remove internal fields
if (m.tool_calls?.length) {
const cleanedToolCalls = m.tool_calls
.filter((tc: any) => tc.id && tc.id.length > 0)
.map((tc: any) => ({
id: tc.id,
type: tc.type,
function: tc.function
}))
if (cleanedToolCalls.length > 0) {
msg.tool_calls = cleanedToolCalls
}
}
return msg
})
await this.calcAndUpdateUsage(session_id, cState, emit)
} catch (err: any) {
this.replaceState(session_id, 'compression.completed', {
event: 'compression.completed',
compressed: false,
totalMessages: history.length,
resultMessages: history.length,
beforeTokens: totalTokens,
afterTokens: totalTokens,
summaryTokens: 0,
verbatimCount: history.length,
compressedStartIndex: -1,
error: err.message,
})
logger.warn(err, '[chat-run-socket] compression failed for session %s, using raw history', session_id)
emit('compression.completed', {
event: 'compression.completed',
compressed: false,
totalMessages: history.length,
resultMessages: history.length,
beforeTokens: totalTokens,
afterTokens: totalTokens,
summaryTokens: 0,
verbatimCount: history.length,
compressedStartIndex: -1,
error: err.message,
})
}
}
}
body.conversation_history = history
}
} catch (err) {
logger.warn(err, '[chat-run-socket] failed to load conversation history for session %s', session_id)
}
}
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
// Convert input from ContentBlock[] to Anthropic format (with base64 images)
if (isContentBlockArray(input)) {
body.input = await convertContentBlocks(input)
}
// Debug: write history to JSON file for analysis (before conversion)
// Convert conversation_history from OpenAI format to Anthropic format
if (body.conversation_history && Array.isArray(body.conversation_history)) {
body.conversation_history = convertHistoryFormat(body.conversation_history)
}
body.stream = true
body.store = false
const abortController = new AbortController()
if (session_id) {
const state = this.getOrCreateSession(session_id)
state.isWorking = true
state.runId = undefined
state.abortController = abortController
}
const res = await fetch(`${upstream}/v1/responses`, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: abortController.signal,
})
if (!res.ok) {
const text = await res.text().catch(() => '')
const queueLen = session_id ? this.sessionMap.get(session_id)?.queue?.length ?? 0 : 0
if (session_id) await this.markCompleted(socket, session_id, { event: 'run.failed' })
emit('run.failed', { event: 'run.failed', error: `Upstream ${res.status}: ${text}`, queue_remaining: queueLen })
if (session_id && queueLen > 0) this.dequeueNextQueuedRun(socket, session_id)
return
}
if (!res.body) {
const queueLen = session_id ? this.sessionMap.get(session_id)?.queue?.length ?? 0 : 0
if (session_id) await this.markCompleted(socket, session_id, { event: 'run.failed' })
emit('run.failed', { event: 'run.failed', error: 'Upstream response stream missing', queue_remaining: queueLen })
if (session_id && queueLen > 0) this.dequeueNextQueuedRun(socket, session_id)
return
}
let responseId: string | undefined
for await (const frame of readSseFrames(res.body)) {
let parsed: any
try {
parsed = JSON.parse(frame.data)
} catch {
continue
}
const upstreamEvent = parsed.type || frame.event || parsed.event
logger.info('[chat-run-socket] upstream response event: %s', upstreamEvent)
if (session_id) {
const state = this.sessionMap.get(session_id)
if (state) {
const mapped = this.applyResponseStreamEvent(state, session_id, runMarker, upstreamEvent, parsed)
if (mapped) {
if (mapped.runId) {
responseId = mapped.runId
state.runId = responseId
}
emit(mapped.event, mapped.payload)
}
}
}
if (upstreamEvent === 'response.completed' || upstreamEvent === 'response.failed') {
if (session_id && this.sessionMap.get(session_id)?.isAborting) {
logger.info({
sessionId: session_id,
runId: responseId,
event: upstreamEvent,
}, '[chat-run-socket][abort] suppressing upstream terminal event during abort')
return
}
const queueLen = session_id ? this.sessionMap.get(session_id)?.queue?.length ?? 0 : 0
if (session_id) await this.markCompleted(socket, session_id, {
event: upstreamEvent === 'response.completed' ? 'run.completed' : 'run.failed',
run_id: responseId,
})
const finalOutput = parsed.response || parsed
const finalText = extractResponseText(finalOutput)
if (upstreamEvent === 'response.completed' && session_id) {
const usage = finalOutput.usage || {}
updateUsage(session_id, {
inputTokens: usage.input_tokens ?? usage.inputTokens ?? 0,
outputTokens: usage.output_tokens ?? usage.outputTokens ?? 0,
cacheReadTokens: usage.cache_read_tokens ?? usage.cacheReadTokens ?? 0,
cacheWriteTokens: usage.cache_write_tokens ?? usage.cacheWriteTokens ?? 0,
reasoningTokens: usage.reasoning_tokens ?? usage.reasoningTokens ?? 0,
model: finalOutput.model || '',
profile: this.sessionMap.get(session_id)?.profile,
})
}
const eventName = upstreamEvent === 'response.completed' ? 'run.completed' : 'run.failed'
emit(eventName, {
event: eventName,
run_id: responseId || finalOutput.id,
response_id: responseId || finalOutput.id,
output: finalText,
usage: finalOutput.usage,
error: finalOutput.error || parsed.error,
queue_remaining: queueLen,
})
if (session_id && queueLen > 0) {
this.dequeueNextQueuedRun(socket, session_id)
}
return
}
}
const queueLen = session_id ? this.sessionMap.get(session_id)?.queue?.length ?? 0 : 0
if (session_id) await this.markCompleted(socket, session_id, { event: 'run.failed', run_id: responseId })
emit('run.failed', {
event: 'run.failed',
run_id: responseId,
response_id: responseId,
error: 'Response stream ended without a terminal event',
queue_remaining: queueLen,
})
if (session_id && queueLen > 0) this.dequeueNextQueuedRun(socket, session_id)
} catch (err: any) {
const queueLen = session_id ? this.sessionMap.get(session_id)?.queue?.length ?? 0 : 0
if (session_id) {
void this.markCompleted(socket, session_id, { event: 'run.failed' }).then(() => {
emit('run.failed', { event: 'run.failed', error: err.message, queue_remaining: queueLen })
if (queueLen > 0) this.dequeueNextQueuedRun(socket, session_id)
})
} else {
emit('run.failed', { event: 'run.failed', error: err.message })
}
}
}
private applyResponseStreamEvent(
state: SessionState,
sessionId: string,
runMarker: string | undefined,
eventType: string,
parsed: any,
): { event: string; payload: any; runId?: string } | null {
const run = this.getResponseRunState(state, runMarker)
const now = () => Math.floor(Date.now() / 1000)
if (eventType === 'response.created') {
const response = parsed.response || parsed
run.responseId = response.id || run.responseId
return {
event: 'run.started',
runId: run.responseId,
payload: {
event: 'run.started',
run_id: run.responseId,
response_id: run.responseId,
status: response.status || 'in_progress',
queue_length: state.queue.length || 0,
},
}
}
if (eventType === 'response.output_text.delta') {
const deltaText = parsed.delta || parsed.text || ''
if (!deltaText) return null
run.text += deltaText
const last = [...state.messages].reverse().find(m => m.runMarker === runMarker)
if (last?.role === 'assistant' && last.finish_reason == null && !last.tool_calls?.length) {
last.content += deltaText
} else {
state.messages.push({
id: state.messages.length + 1,
session_id: sessionId,
runMarker,
role: 'assistant',
content: deltaText,
timestamp: now(),
})
}
return {
event: 'message.delta',
payload: {
event: 'message.delta',
run_id: run.responseId,
response_id: run.responseId,
delta: deltaText,
},
}
}
if (eventType === 'response.output_text.done') {
const text = parsed.text || run.text
this.insertResponseTextOnce(state, sessionId, runMarker, text)
return null
}
if (eventType === 'response.output_item.added') {
const item = parsed.item || parsed.output_item || parsed
if (item.type !== 'function_call') return null
const callId = item.call_id || item.id
if (!callId) return null
run.toolCalls.set(callId, responseFunctionCallToToolCall(item))
return null
}
if (eventType === 'response.output_item.done') {
const item = parsed.item || parsed.output_item || parsed
if (item.type === 'function_call') {
const callId = item.call_id || item.id
if (!callId) return null
const toolCall = responseFunctionCallToToolCall(item)
run.toolCalls.set(callId, toolCall)
const key = `assistant:${callId}`
if (!run.insertedKeys.has(key)) {
run.insertedKeys.add(key)
state.messages.push({
id: state.messages.length + 1,
session_id: sessionId,
runMarker,
role: 'assistant',
content: '',
tool_calls: [toolCall],
finish_reason: 'tool_calls',
timestamp: now(),
})
}
return {
event: 'tool.started',
payload: {
event: 'tool.started',
run_id: run.responseId,
response_id: run.responseId,
tool_call_id: callId,
tool: toolCall.function.name,
name: toolCall.function.name,
arguments: toolCall.function.arguments,
preview: summarizeToolArguments(toolCall.function.arguments),
},
}
}
if (item.type === 'function_call_output') {
const callId = item.call_id || item.id
if (!callId) return null
const key = `tool:${callId}`
const output = typeof item.output === 'string' ? item.output : JSON.stringify(item.output ?? '')
const toolName = run.toolCalls.get(callId)?.function?.name || null
if (!run.insertedKeys.has(key)) {
run.insertedKeys.add(key)
state.messages.push({
id: state.messages.length + 1,
session_id: sessionId,
runMarker,
role: 'tool',
content: output,
tool_call_id: callId,
tool_name: toolName,
timestamp: now(),
})
}
return {
event: 'tool.completed',
payload: {
event: 'tool.completed',
run_id: run.responseId,
response_id: run.responseId,
tool_call_id: callId,
tool: toolName,
name: toolName,
output,
},
}
}
}
if (eventType === 'response.completed') {
const response = parsed.response || parsed
run.responseId = response.id || run.responseId
const output = Array.isArray(response.output) ? response.output : []
for (const item of output) {
if (item.type === 'function_call') {
this.applyResponseStreamEvent(state, sessionId, runMarker, 'response.output_item.done', { item })
} else if (item.type === 'function_call_output') {
this.applyResponseStreamEvent(state, sessionId, runMarker, 'response.output_item.done', { item })
}
}
this.insertResponseTextOnce(state, sessionId, runMarker, extractResponseText(response))
}
return null
}
private getResponseRunState(state: SessionState, runMarker?: string): ResponseRunState {
if (!state.responseRun || state.responseRun.runMarker !== runMarker) {
state.responseRun = {
runMarker,
text: '',
textInserted: false,
insertedKeys: new Set<string>(),
toolCalls: new Map<string, any>(),
}
}
return state.responseRun
}
private insertResponseTextOnce(
state: SessionState,
sessionId: string,
runMarker: string | undefined,
text: string,
) {
const run = this.getResponseRunState(state, runMarker)
if (run.textInserted || !text?.trim()) return
run.textInserted = true
const lastIdx = [...state.messages].map((m, i) => ({ m, i }))
.reverse().find(({ m }) => m.runMarker === runMarker)
if (lastIdx && lastIdx.m.role === 'assistant' && !lastIdx.m.tool_calls?.length) {
lastIdx.m.content = text
lastIdx.m.finish_reason = 'stop'
} else {
state.messages.push({
id: state.messages.length + 1,
session_id: sessionId,
runMarker,
role: 'assistant',
content: text,
finish_reason: 'stop',
timestamp: Math.floor(Date.now() / 1000),
})
}
}
/** Flush all non-user messages for this run to DB in order. */
private flushResponseRunToDb(state: SessionState, sessionId: string) {
const run = state.responseRun
if (!run?.runMarker) return
let flushed = 0
for (const msg of state.messages) {
if (msg.runMarker !== run.runMarker) continue
if (msg.role === 'user') continue
addMessage({
session_id: sessionId,
role: msg.role,
content: msg.content || '',
tool_call_id: msg.tool_call_id ?? null,
tool_calls: msg.tool_calls ?? null,
tool_name: msg.tool_name ?? null,
finish_reason: msg.finish_reason ?? null,
timestamp: msg.timestamp,
})
flushed++
}
logger.info('[chat-run-socket] flushResponseRunToDb: flushed %d messages for session %s',
flushed, sessionId)
}
// --- Abort handler ---
private async handleAbort(socket: Socket, sessionId: string) {
const state = this.sessionMap.get(sessionId)
if (!state?.isWorking || (!state.runId && !state.abortController)) {
logger.info({ sessionId }, '[chat-run-socket][abort] ignored: no active run')
if (state) {
state.isWorking = false
state.isAborting = false
state.abortController = undefined
state.runId = undefined
state.events = []
}
this.emitToSession(socket, sessionId, 'abort.completed', {
event: 'abort.completed',
synced: false,
ignored: true,
})
return
}
const runId = state.runId
state.isAborting = true
this.replaceState(sessionId, 'abort.started', {
event: 'abort.started',
run_id: runId,
graceMs: 5000,
})
this.emitToSession(socket, sessionId, 'abort.started', {
event: 'abort.started',
run_id: runId,
graceMs: 5000,
})
logger.info({ sessionId, runId }, '[chat-run-socket][abort] started')
// Flush in-memory assistant text to DB before aborting the stream.
this.flushResponseRunToDb(state, sessionId)
if (state.abortController) {
state.abortController.abort()
}
await this.markAbortCompleted(socket, sessionId, runId || 'response_stream')
}
/** Mark a session run as completed/failed so reconnecting clients get notified */
private async markCompleted(_socket: Socket, sessionId: string, _info: { event: string; run_id?: string }) {
const state = this.sessionMap.get(sessionId)
if (state) {
if (state.isAborting) {
logger.info({
sessionId,
runId: state.runId,
}, '[chat-run-socket][abort] terminal upstream event observed; abort handler will finish cleanup')
return
}
state.isWorking = false
state.abortController = undefined
state.runId = undefined
state.events = []
this.flushResponseRunToDb(state, sessionId)
state.responseRun = undefined
state.profile = undefined
updateSessionStats(sessionId)
const emit = (event: string, payload: any) => {
this.nsp.to(`session:${sessionId}`).emit(event, { ...payload, session_id: sessionId })
}
await this.calcAndUpdateUsage(sessionId, state, emit)
}
}
private dequeueNextQueuedRun(socket: Socket, sessionId: string, fallbackProfile = 'default') {
const state = this.sessionMap.get(sessionId)
if (!state?.queue.length) return false
const next = state.queue.shift()!
logger.info('[chat-run-socket] dequeuing queued run for session %s (remaining: %d)', sessionId, state.queue.length)
this.nsp.to(`session:${sessionId}`).emit('run.queued', {
event: 'run.queued',
session_id: sessionId,
queue_length: state.queue.length,
})
void this.handleRun(socket, {
input: next.input,
session_id: sessionId,
model: next.model,
instructions: next.instructions,
}, next.profile || fallbackProfile, true)
return true
}
private async markAbortCompleted(socket: Socket, sessionId: string, runId: string) {
const state = this.sessionMap.get(sessionId)
if (!state) return
const profile = state.profile
updateSessionStats(sessionId)
state.isWorking = false
state.isAborting = false
state.profile = undefined
state.abortController = undefined
state.runId = undefined
state.responseRun = undefined
// Process queued messages after abort completes
if (state.queue.length > 0) {
const next = state.queue.shift()!
logger.info('[chat-run-socket][abort] dequeuing queued run for session %s (remaining: %d)', sessionId, state.queue.length)
this.replaceState(sessionId, 'abort.completed', {
event: 'abort.completed',
run_id: runId,
synced: true,
queue_length: state.queue.length + 1,
})
this.emitToSession(socket, sessionId, 'abort.completed', {
event: 'abort.completed',
run_id: runId,
synced: true,
queue_length: state.queue.length + 1,
})
this.emitToSession(socket, sessionId, 'run.queued', {
event: 'run.queued',
queue_length: state.queue.length,
})
state.events = []
void this.handleRun(socket, {
input: next.input,
session_id: sessionId,
model: next.model,
instructions: next.instructions,
}, next.profile || profile || 'default', true)
return
}
state.events = []
this.replaceState(sessionId, 'abort.completed', {
event: 'abort.completed',
run_id: runId,
synced: true,
})
this.emitToSession(socket, sessionId, 'abort.completed', {
event: 'abort.completed',
run_id: runId,
synced: true,
})
logger.info({ sessionId, runId, synced: true }, '[chat-run-socket][abort] completed')
}
/**
* Calculate usage from DB and update state + emit to clients.
* @returns { inputTokens, outputTokens } for the caller to use
*/
private async calcAndUpdateUsage(
sid: string, state: SessionState, emit: (event: string, payload: any) => void,
): Promise<{ inputTokens: number; outputTokens: number }> {
try {
const detail = useLocalSessionStore()
? getSessionDetail(sid)
: await getSessionDetailFromDb(sid)
const msgs = detail?.messages
?.filter(m => m.role === 'user' || m.role === 'assistant' || m.role === 'tool') || []
const snapshot = getCompressionSnapshot(sid)
let inputTokens: number
let outputTokens: number
if (snapshot && msgs.length) {
const newMessages = msgs.slice(snapshot.lastMessageIndex + 1)
inputTokens = countTokens(SUMMARY_PREFIX + snapshot.summary) +
newMessages.filter(m => m.role === 'user').reduce((sum, m) => sum + countTokens(m.content || ''), 0)
outputTokens = newMessages
.filter(m => m.role === 'assistant' || m.role === 'tool')
.reduce((sum, m) => sum + countTokens(m.content || '') + countTokens(m.tool_calls + '' || ''), 0)
} else {
inputTokens = msgs.filter(m => m.role === 'user').reduce((sum, m) => sum + countTokens(m.content || ''), 0)
outputTokens = msgs
.filter(m => m.role === 'assistant' || m.role === 'tool')
.reduce((sum, m) => sum + countTokens(m.content || '') + countTokens(m.tool_calls + '' || ''), 0)
}
state.inputTokens = inputTokens
state.outputTokens = outputTokens
emit('usage.updated', {
event: 'usage.updated',
session_id: sid,
inputTokens,
outputTokens,
})
return { inputTokens, outputTokens }
} catch (err: any) {
logger.warn(err, '[chat-run-socket] failed to calculate usage for session %s', sid)
return { inputTokens: 0, outputTokens: 0 }
}
}
/** Get or create session state in sessionMap */
private getOrCreateSession(sessionId: string): SessionState {
let state = this.sessionMap.get(sessionId)
if (!state) {
state = { messages: [], isWorking: false, events: [], queue: [] }
this.sessionMap.set(sessionId, state)
}
return state
}
/** Append a state event for a session (used for replay on reconnect) */
private pushState(sessionId: string, event: string, data: any) {
const state = this.getOrCreateSession(sessionId)
state.events.push({ event, data })
}
/** Replace the last state with the same event name, or append if different */
private replaceState(sessionId: string, event: string, data: any) {
const state = this.sessionMap.get(sessionId)
if (state) {
const idx = state.events.findIndex(s => s.event === event)
if (idx >= 0) {
state.events[idx] = { event, data }
return
}
}
this.pushState(sessionId, event, data)
}
private emitToSession(socket: Socket, sessionId: string, event: string, payload: any) {
const tagged = { ...payload, session_id: sessionId }
this.nsp.to(`session:${sessionId}`).emit(event, tagged)
if (!this.nsp.adapter.rooms.get(`session:${sessionId}`)?.size && socket.connected) {
socket.emit(event, tagged)
}
}
/** Close all active upstream response streams */
close() {
for (const [sessionId, state] of this.sessionMap.entries()) {
if (state.abortController) {
try {
state.abortController.abort()
} catch (e) {
logger.warn(e, '[chat-run-socket] failed to abort controller for session %s', sessionId)
}
}
}
this.sessionMap.clear()
logger.info('[chat-run-socket] closed all connections and cleared state')
}
}
async function* readSseFrames(stream: ReadableStream<Uint8Array>): AsyncGenerator<{ event?: string; data: string }> {
const decoder = new TextDecoder()
const reader = stream.getReader()
let buffer = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let boundary = buffer.indexOf('\n\n')
while (boundary >= 0) {
const raw = buffer.slice(0, boundary)
buffer = buffer.slice(boundary + 2)
const frame = parseSseFrame(raw)
if (frame?.data) yield frame
boundary = buffer.indexOf('\n\n')
}
}
buffer += decoder.decode()
const frame = parseSseFrame(buffer)
if (frame?.data) yield frame
} finally {
reader.releaseLock()
}
}
function parseSseFrame(raw: string): { event?: string; data: string } | null {
let event: string | undefined
const data: string[] = []
for (const line of raw.split(/\r?\n/)) {
if (!line || line.startsWith(':')) continue
if (line.startsWith('event:')) {
event = line.slice(6).trim()
} else if (line.startsWith('data:')) {
data.push(line.slice(5).trimStart())
}
}
if (data.length === 0) return null
return { event, data: data.join('\n') }
}
function responseFunctionCallToToolCall(item: any): any {
const callId = item.call_id || item.id || ''
const name = item.name || item.function?.name || ''
let args = item.arguments ?? item.function?.arguments ?? '{}'
if (typeof args !== 'string') {
args = JSON.stringify(args ?? {})
}
return {
id: callId,
type: 'function',
function: {
name,
arguments: args || '{}',
},
}
}
function summarizeToolArguments(args: string): string | undefined {
if (!args) return undefined
try {
const parsed = JSON.parse(args)
if (!parsed || typeof parsed !== 'object') return args.slice(0, 120)
const preferredKeys = ['cmd', 'command', 'code', 'query', 'path', 'url', 'prompt']
for (const key of preferredKeys) {
const value = parsed[key]
if (typeof value === 'string' && value.trim()) {
return value.replace(/\s+/g, ' ').slice(0, 160)
}
}
const first = Object.entries(parsed).find(([, value]) => typeof value === 'string' && value.trim())
if (first) return String(first[1]).replace(/\s+/g, ' ').slice(0, 160)
return JSON.stringify(parsed).slice(0, 160)
} catch {
return args.replace(/\s+/g, ' ').slice(0, 160)
}
}
function extractResponseText(response: any): string {
const output = Array.isArray(response?.output) ? response.output : []
const parts: string[] = []
for (const item of output) {
if (item.type !== 'message') continue
const content = Array.isArray(item.content) ? item.content : []
for (const part of content) {
if (part.type === 'output_text' || part.type === 'text') {
parts.push(part.text || '')
}
}
}
if (parts.length > 0) return parts.join('')
return typeof response?.output_text === 'string' ? response.output_text : ''
}