diff --git a/packages/client/src/api/hermes/chat.ts b/packages/client/src/api/hermes/chat.ts index 12bfcb82..877c8c28 100644 --- a/packages/client/src/api/hermes/chat.ts +++ b/packages/client/src/api/hermes/chat.ts @@ -77,6 +77,7 @@ const sessionEventHandlers = new Map void onAbortCompleted: (event: RunEvent) => void onUsageUpdated: (event: RunEvent) => void + onSessionCommand?: (event: RunEvent) => void onRunQueued?: (event: RunEvent) => void onApprovalRequested?: (event: RunEvent) => void onApprovalResolved?: (event: RunEvent) => void @@ -291,6 +292,16 @@ function globalUsageUpdatedHandler(event: RunEvent): void { } } +function globalSessionCommandHandler(event: RunEvent): void { + const sid = event.session_id + if (!sid) return + + const handlers = sessionEventHandlers.get(sid) + if (handlers?.onSessionCommand) { + handlers.onSessionCommand(event) + } +} + function globalApprovalRequestedHandler(event: RunEvent): void { const sid = event.session_id if (!sid) return @@ -334,6 +345,7 @@ export function registerSessionHandlers( onAbortStarted: (event: RunEvent) => void onAbortCompleted: (event: RunEvent) => void onUsageUpdated: (event: RunEvent) => void + onSessionCommand?: (event: RunEvent) => void onRunQueued?: (event: RunEvent) => void onApprovalRequested?: (event: RunEvent) => void onApprovalResolved?: (event: RunEvent) => void @@ -436,6 +448,7 @@ export function connectChatRun(): Socket { // Usage events chatRunSocket.on('usage.updated', globalUsageUpdatedHandler) + chatRunSocket.on('session.command', globalSessionCommandHandler) globalListenersRegistered = true } @@ -565,6 +578,14 @@ export function startRunViaSocket( if (closed) return onEvent(evt) }, + onSessionCommand: (evt: RunEvent) => { + if (closed) return + onEvent(evt) + if ((evt as any).terminal === false) return + closed = true + sessionEventHandlers.delete(sid) + onDone() + }, onRunQueued: (evt: RunEvent) => { if (closed) return onEvent(evt) diff --git a/packages/client/src/api/hermes/sessions.ts b/packages/client/src/api/hermes/sessions.ts index 95ef7ced..5e463c8c 100644 --- a/packages/client/src/api/hermes/sessions.ts +++ b/packages/client/src/api/hermes/sessions.ts @@ -36,7 +36,7 @@ export interface SessionSearchResult extends SessionSummary { export interface HermesMessage { id: number session_id: string - role: 'user' | 'assistant' | 'system' | 'tool' + role: 'user' | 'assistant' | 'system' | 'tool' | 'command' content: string tool_call_id: string | null tool_calls: any[] | null diff --git a/packages/client/src/components/hermes/chat/ChatInput.vue b/packages/client/src/components/hermes/chat/ChatInput.vue index bdef803f..efdea73a 100644 --- a/packages/client/src/components/hermes/chat/ChatInput.vue +++ b/packages/client/src/components/hermes/chat/ChatInput.vue @@ -6,7 +6,7 @@ import { useProfilesStore } from '@/stores/hermes/profiles' import { fetchContextLength } from '@/api/hermes/sessions' import { setModelContext } from '@/api/hermes/model-context' import { NButton, NTooltip, NSwitch, NModal, NInputNumber, useMessage } from 'naive-ui' -import { computed, ref, onMounted, watch } from 'vue' +import { computed, ref, nextTick, onMounted, onUnmounted, watch } from 'vue' import { useI18n } from 'vue-i18n' const chatStore = useChatStore() @@ -14,12 +14,37 @@ const { t } = useI18n() const message = useMessage() const inputText = ref('') const textareaRef = ref() +const commandDropdownRef = ref() const fileInputRef = ref() const attachments = ref([]) const isDragging = ref(false) const dragCounter = ref(0) const isComposing = ref(false) +const bridgeCommands = computed(() => [ + { name: 'usage', args: '', description: t('chat.slashCommands.usage') }, + { name: 'status', args: '', description: t('chat.slashCommands.status') }, + { name: 'abort', args: '', description: t('chat.slashCommands.abort') }, + { name: 'queue', args: t('chat.slashCommandArgs.message'), description: t('chat.slashCommands.queue') }, + { name: 'clear', args: '', description: t('chat.slashCommands.clear') }, + { name: 'clear', args: '--history', insertText: 'clear --history', description: t('chat.slashCommands.clearHistory') }, + { name: 'title', args: t('chat.slashCommandArgs.title'), description: t('chat.slashCommands.title') }, + { name: 'compress', args: '', description: t('chat.slashCommands.compress') }, + { name: 'steer', args: t('chat.slashCommandArgs.text'), description: t('chat.slashCommands.steer') }, + { name: 'destroy', args: '', description: t('chat.slashCommands.destroy') }, +]) + +const slashActive = ref(false) +const slashQuery = ref('') +const slashActiveIndex = ref(0) +const isBridgeSession = computed(() => chatStore.activeSession?.source === 'cli') +const filteredBridgeCommands = computed(() => { + const query = slashQuery.value.toLowerCase() + return bridgeCommands.value.filter(command => + command.name.includes(query) || command.insertText?.includes(query), + ) +}) + // 自定义高度拖拽 const textareaHeight = ref(null) // null = auto @@ -73,6 +98,44 @@ watch(autoPlaySpeech, (value) => { const canSend = computed(() => inputText.value.trim() || attachments.value.length > 0) +function scrollCommandIntoView() { + nextTick(() => { + if (!commandDropdownRef.value) return + const active = commandDropdownRef.value.querySelector('.active') as HTMLElement | null + active?.scrollIntoView({ block: 'nearest', behavior: 'instant' }) + }) +} + +function updateSlashState() { + if (!isBridgeSession.value) { + slashActive.value = false + return + } + const el = textareaRef.value + if (!el) return + const cursorPos = el.selectionStart + const beforeCursor = inputText.value.slice(0, cursorPos) + if (!beforeCursor.startsWith('/') || beforeCursor.includes(' ') || beforeCursor.includes('\n')) { + slashActive.value = false + return + } + slashQuery.value = beforeCursor.slice(1) + slashActiveIndex.value = 0 + slashActive.value = filteredBridgeCommands.value.length > 0 +} + +function selectBridgeCommand(command: { name: string; args: string; insertText?: string }) { + inputText.value = `/${command.insertText || command.name} ` + slashActive.value = false + nextTick(() => { + const el = textareaRef.value + if (!el) return + const pos = inputText.value.length + el.setSelectionRange(pos, pos) + el.focus() + }) +} + // --- Context info --- const contextLength = ref(200000) @@ -231,6 +294,7 @@ function handleSend() { chatStore.sendMessage(text, attachments.value.length > 0 ? attachments.value : undefined) inputText.value = '' attachments.value = [] + slashActive.value = false if (textareaRef.value) { textareaRef.value.style.height = 'auto' @@ -244,6 +308,7 @@ function handleCompositionStart() { function handleCompositionEnd() { requestAnimationFrame(() => { isComposing.value = false + updateSlashState() }) } @@ -252,6 +317,31 @@ function isImeEnter(e: KeyboardEvent): boolean { } function handleKeydown(e: KeyboardEvent) { + if (slashActive.value && filteredBridgeCommands.value.length > 0) { + if (e.key === 'ArrowDown') { + e.preventDefault() + slashActiveIndex.value = (slashActiveIndex.value + 1) % filteredBridgeCommands.value.length + scrollCommandIntoView() + return + } + if (e.key === 'ArrowUp') { + e.preventDefault() + slashActiveIndex.value = (slashActiveIndex.value - 1 + filteredBridgeCommands.value.length) % filteredBridgeCommands.value.length + scrollCommandIntoView() + return + } + if (e.key === 'Enter' || e.key === 'Tab') { + e.preventDefault() + selectBridgeCommand(filteredBridgeCommands.value[slashActiveIndex.value]) + return + } + if (e.key === 'Escape') { + e.preventDefault() + slashActive.value = false + return + } + } + if (e.key !== 'Enter' || e.shiftKey) return if (isImeEnter(e)) return @@ -260,13 +350,34 @@ function handleKeydown(e: KeyboardEvent) { } function handleInput(e: Event) { + const el = e.target as HTMLTextAreaElement + if (!isComposing.value) updateSlashState() // 用户手动拖拽自定义高度时,不覆盖 if (textareaHeight.value !== null) return - const el = e.target as HTMLTextAreaElement el.style.height = 'auto' el.style.height = Math.min(el.scrollHeight, 100) + 'px' } +function handleCommandHover(index: number) { + slashActiveIndex.value = index +} + +function onDocumentMousedown(e: MouseEvent) { + if (!slashActive.value) return + const target = e.target as HTMLElement + if (!target.closest('.slash-command-dropdown') && !target.closest('.input-wrapper')) { + slashActive.value = false + } +} + +onMounted(() => { + document.addEventListener('mousedown', onDocumentMousedown) +}) + +onUnmounted(() => { + document.removeEventListener('mousedown', onDocumentMousedown) +}) + function removeAttachment(id: string) { const idx = attachments.value.findIndex(a => a.id === id) if (idx !== -1) { @@ -396,6 +507,26 @@ function isImage(type: string): boolean { @input="handleInput" @paste="handlePaste" > + +
+
+ /{{ command.name }} + {{ command.args }} + {{ command.description }} +
+
+
props.message.role === "system"); +const isCommandMessage = computed(() => props.message.role === "command" || props.message.systemType === "command"); +const isCommandError = computed(() => props.message.role === "command" && props.message.systemType === "error"); +const isStatusCommand = computed(() => isCommandMessage.value && props.message.commandAction === "status"); +const statusItems = computed(() => { + const data = props.message.commandData || {}; + return [ + { key: "status", value: data.isWorking ? "running" : "idle" }, + { key: "source", value: data.source }, + { key: "profile", value: data.profile }, + { key: "model", value: data.model || "-" }, + { key: "queue", value: data.queueLength ?? 0 }, + { key: "run", value: data.runId || "-" }, + ]; +}); // Parse ContentBlock[] from JSON string const contentBlocks = computed(() => { @@ -572,7 +586,15 @@ onBeforeUnmount(() => { class="msg-avatar" />
-
+
{ :content="message.content" /> + + +
+ / +
+ + {{ item.key }} + {{ item.value }} + +
+
+
+ / + +
+ @@ -806,6 +851,10 @@ onBeforeUnmount(() => { align-items: flex-start; } + &.command { + align-items: flex-start; + } + &.highlight { .message-bubble { box-shadow: 0 0 0 1px rgba(var(--accent-primary-rgb), 0.45); @@ -855,6 +904,20 @@ onBeforeUnmount(() => { background-color: rgba(var(--warning-rgb), 0.06); } + &.command { + border-left: none; + border: 1px solid rgba(var(--accent-primary-rgb), 0.12); + background-color: rgba(var(--accent-primary-rgb), 0.04); + color: $text-secondary; + max-width: min(100%, 960px); + padding: 8px 10px; + } + + &.command-error { + border-color: rgba(var(--warning-rgb), 0.28); + background-color: rgba(var(--warning-rgb), 0.06); + } + &.speech-playing { box-shadow: 0 0 0 2px #ff6b6b, @@ -864,6 +927,74 @@ onBeforeUnmount(() => { } } +.command-result { + display: flex; + align-items: flex-start; + gap: 8px; + min-width: 0; + + :deep(.markdown-body) { + min-width: 0; + } + + :deep(.markdown-body p) { + margin: 0; + } +} + +.command-status { + align-items: center; +} + +.command-status-grid { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + overflow-x: auto; + white-space: nowrap; + scrollbar-width: thin; +} + +.command-status-item { + display: inline-flex; + align-items: center; + gap: 4px; + flex: 0 0 auto; + padding: 2px 7px; + border: 1px solid rgba(var(--accent-primary-rgb), 0.1); + border-radius: 999px; + background: rgba(var(--accent-primary-rgb), 0.035); + line-height: 1.4; +} + +.command-status-key { + color: $text-muted; + font-size: 11px; +} + +.command-status-value { + color: $text-primary; + font-family: $font-code; + font-size: 11px; +} + +.command-result-icon { + width: 18px; + height: 18px; + flex: 0 0 18px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: rgba(var(--accent-primary-rgb), 0.1); + color: $accent-primary; + font-family: $font-code; + font-size: 12px; + line-height: 1; + margin-top: 2px; +} + @keyframes rainbow-glow { 0% { box-shadow: diff --git a/packages/client/src/i18n/locales/de.ts b/packages/client/src/i18n/locales/de.ts index f2fd9607..ab08f421 100644 --- a/packages/client/src/i18n/locales/de.ts +++ b/packages/client/src/i18n/locales/de.ts @@ -121,6 +121,23 @@ export default { contextEditFailed: 'Aktualisierung fehlgeschlagen', emptyState: 'Starten Sie eine Konversation mit Hermes Agent', inputPlaceholder: 'Nachricht eingeben... (Enter zum Senden, Shift+Enter fur neue Zeile)', + slashCommandArgs: { + message: '', + title: '', + text: '', + }, + slashCommands: { + usage: 'Nutzung der aktuellen Sitzung berechnen', + status: 'Sitzungsstatus und Warteschlange anzeigen', + abort: 'Aktiven Bridge-Lauf stoppen', + queue: 'Nachricht hinter dem aktiven Lauf einreihen', + clear: 'Aktuelle Anzeige leeren', + clearHistory: 'Gespeicherten Nachrichtenverlauf dieser Sitzung löschen', + title: 'Diese Sitzung umbenennen', + compress: 'Kontextkomprimierung im Leerlauf ausführen', + steer: 'Steuertext an den aktiven Bridge-Lauf senden', + destroy: 'Bridge-Agent für diese Sitzung freigeben', + }, attachFiles: 'Dateien anhangen', messageQueue: 'Nachrichtenwarteschlange', removeQueuedMessage: 'Nachricht aus Warteschlange entfernen', diff --git a/packages/client/src/i18n/locales/en.ts b/packages/client/src/i18n/locales/en.ts index 864db9d0..a354b503 100644 --- a/packages/client/src/i18n/locales/en.ts +++ b/packages/client/src/i18n/locales/en.ts @@ -134,6 +134,23 @@ export default { emptyState: 'Start a conversation with Hermes Agent', cliEmptyState: 'Start a CLI chat session', inputPlaceholder: 'Type a message... (Enter to send, Shift+Enter for new line)', + slashCommandArgs: { + message: '', + title: '', + text: '<text>', + }, + slashCommands: { + usage: 'Calculate current session usage', + status: 'Show session status and queue', + abort: 'Stop the active bridge run', + queue: 'Queue a message behind the active run', + clear: 'Clear the current display', + clearHistory: 'Delete this session’s stored message history', + title: 'Rename this session', + compress: 'Run context compression while idle', + steer: 'Send steering text to the active bridge run', + destroy: 'Release the bridge agent for this session', + }, attachFiles: 'Attach files', autoPlaySpeech: 'Auto-play voice', messageQueue: 'Message queue', diff --git a/packages/client/src/i18n/locales/es.ts b/packages/client/src/i18n/locales/es.ts index b0bbcf15..b67a0e84 100644 --- a/packages/client/src/i18n/locales/es.ts +++ b/packages/client/src/i18n/locales/es.ts @@ -121,6 +121,23 @@ export default { contextEditFailed: 'Error en la actualización', emptyState: 'Inicia una conversacion con Hermes Agent', inputPlaceholder: 'Escribe un mensaje... (Enter para enviar, Shift+Enter para nueva linea)', + slashCommandArgs: { + message: '<mensaje>', + title: '<titulo>', + text: '<texto>', + }, + slashCommands: { + usage: 'Calcular el uso de la sesión actual', + status: 'Mostrar estado de sesión y cola', + abort: 'Detener la ejecución activa de Bridge', + queue: 'Poner un mensaje en cola tras la ejecución activa', + clear: 'Limpiar la vista actual', + clearHistory: 'Eliminar el historial de mensajes guardado de esta sesión', + title: 'Renombrar esta sesión', + compress: 'Ejecutar compresión de contexto cuando esté inactiva', + steer: 'Enviar texto de guía a la ejecución activa de Bridge', + destroy: 'Liberar el agente Bridge de esta sesión', + }, attachFiles: 'Adjuntar archivos', messageQueue: 'Cola de mensajes', removeQueuedMessage: 'Quitar mensaje de la cola', diff --git a/packages/client/src/i18n/locales/fr.ts b/packages/client/src/i18n/locales/fr.ts index 3b4c6489..c6bf6227 100644 --- a/packages/client/src/i18n/locales/fr.ts +++ b/packages/client/src/i18n/locales/fr.ts @@ -121,6 +121,23 @@ export default { contextEditFailed: 'Échec de la mise à jour', emptyState: 'Demarrer une conversation avec Hermes Agent', inputPlaceholder: 'Tapez un message... (Entree pour envoyer, Shift+Entree pour un saut de ligne)', + slashCommandArgs: { + message: '<message>', + title: '<titre>', + text: '<texte>', + }, + slashCommands: { + usage: 'Calculer l’utilisation de la session actuelle', + status: 'Afficher l’état de la session et la file', + abort: 'Arrêter l’exécution Bridge active', + queue: 'Mettre un message en file après l’exécution active', + clear: 'Effacer l’affichage actuel', + clearHistory: 'Supprimer l’historique des messages enregistrés de cette session', + title: 'Renommer cette session', + compress: 'Lancer la compression du contexte au repos', + steer: 'Envoyer un guidage à l’exécution Bridge active', + destroy: 'Libérer l’agent Bridge de cette session', + }, attachFiles: 'Joindre des fichiers', messageQueue: 'File de messages', removeQueuedMessage: 'Retirer le message de la file', diff --git a/packages/client/src/i18n/locales/ja.ts b/packages/client/src/i18n/locales/ja.ts index 2140c687..2c9d56f4 100644 --- a/packages/client/src/i18n/locales/ja.ts +++ b/packages/client/src/i18n/locales/ja.ts @@ -121,6 +121,23 @@ export default { contextEditFailed: '更新に失敗しました', emptyState: 'Hermes Agent と会話を開始しましょう', inputPlaceholder: 'メッセージを入力... (Enter で送信、Shift+Enter で改行)', + slashCommandArgs: { + message: '<メッセージ>', + title: '<タイトル>', + text: '<テキスト>', + }, + slashCommands: { + usage: '現在のセッション使用量を計算', + status: 'セッション状態とキューを表示', + abort: '実行中の Bridge を停止', + queue: '実行中の処理の後ろにメッセージをキュー追加', + clear: '現在の表示をクリア', + clearHistory: 'このセッションの保存済みメッセージ履歴を削除', + title: 'このセッション名を変更', + compress: 'アイドル時にコンテキスト圧縮を実行', + steer: '実行中の Bridge に誘導テキストを送信', + destroy: 'このセッションの Bridge Agent を解放', + }, attachFiles: 'ファイルを添付', messageQueue: 'メッセージキュー', removeQueuedMessage: 'キューのメッセージを削除', diff --git a/packages/client/src/i18n/locales/ko.ts b/packages/client/src/i18n/locales/ko.ts index 00719018..cd76a97a 100644 --- a/packages/client/src/i18n/locales/ko.ts +++ b/packages/client/src/i18n/locales/ko.ts @@ -121,6 +121,23 @@ export default { contextEditFailed: '업데이트 실패', emptyState: 'Hermes Agent와 대화를 시작하세요', inputPlaceholder: '메시지를 입력하세요... (Enter로 전송, Shift+Enter로 줄바꿈)', + slashCommandArgs: { + message: '<메시지>', + title: '<제목>', + text: '<텍스트>', + }, + slashCommands: { + usage: '현재 세션 사용량 계산', + status: '세션 상태와 대기열 표시', + abort: '활성 Bridge 실행 중지', + queue: '활성 실행 뒤에 메시지 대기열 추가', + clear: '현재 표시 내용 지우기', + clearHistory: '이 세션의 저장된 메시지 기록 삭제', + title: '이 세션 이름 변경', + compress: '유휴 상태에서 컨텍스트 압축 실행', + steer: '활성 Bridge 실행에 지시 텍스트 보내기', + destroy: '이 세션의 Bridge Agent 해제', + }, attachFiles: '파일 첨부', messageQueue: '메시지 대기열', removeQueuedMessage: '대기열 메시지 제거', diff --git a/packages/client/src/i18n/locales/pt.ts b/packages/client/src/i18n/locales/pt.ts index 51a69002..52ef18bf 100644 --- a/packages/client/src/i18n/locales/pt.ts +++ b/packages/client/src/i18n/locales/pt.ts @@ -121,6 +121,23 @@ export default { contextEditFailed: 'Falha na atualização', emptyState: 'Inicie uma conversa com o Hermes Agent', inputPlaceholder: 'Digite uma mensagem... (Enter para enviar, Shift+Enter para nova linha)', + slashCommandArgs: { + message: '<mensagem>', + title: '<titulo>', + text: '<texto>', + }, + slashCommands: { + usage: 'Calcular o uso da sessão atual', + status: 'Mostrar status da sessão e fila', + abort: 'Parar a execução ativa do Bridge', + queue: 'Enfileirar uma mensagem após a execução ativa', + clear: 'Limpar a visualização atual', + clearHistory: 'Excluir o histórico de mensagens salvo desta sessão', + title: 'Renomear esta sessão', + compress: 'Executar compressão de contexto quando ocioso', + steer: 'Enviar texto de orientação para a execução ativa do Bridge', + destroy: 'Liberar o Bridge Agent desta sessão', + }, attachFiles: 'Anexar arquivos', messageQueue: 'Fila de mensagens', removeQueuedMessage: 'Remover mensagem da fila', diff --git a/packages/client/src/i18n/locales/zh-TW.ts b/packages/client/src/i18n/locales/zh-TW.ts index 1a6e514f..1eac5004 100644 --- a/packages/client/src/i18n/locales/zh-TW.ts +++ b/packages/client/src/i18n/locales/zh-TW.ts @@ -133,6 +133,23 @@ export default { contextEditFailed: '更新失敗', emptyState: '開始與 Hermes Agent 對話', inputPlaceholder: '輸入訊息... (Enter 發送,Shift+Enter 換行)', + slashCommandArgs: { + message: '<訊息>', + title: '<標題>', + text: '<文字>', + }, + slashCommands: { + usage: '計算目前會話用量', + status: '查看會話狀態和佇列', + abort: '停止目前 Bridge 執行', + queue: '將訊息加入目前執行後的佇列', + clear: '清空目前顯示內容', + clearHistory: '刪除目前會話已儲存的訊息歷史', + title: '重新命名目前會話', + compress: '空閒時觸發上下文壓縮', + steer: '向目前 Bridge 執行傳送引導文字', + destroy: '釋放目前會話的 Bridge Agent', + }, attachFiles: '新增附件', autoPlaySpeech: '自動播放語音', messageQueue: '訊息佇列', diff --git a/packages/client/src/i18n/locales/zh.ts b/packages/client/src/i18n/locales/zh.ts index 7408560d..1f83b9f6 100644 --- a/packages/client/src/i18n/locales/zh.ts +++ b/packages/client/src/i18n/locales/zh.ts @@ -134,6 +134,23 @@ export default { emptyState: '开始与 Hermes Agent 对话', cliEmptyState: '开始 CLI 对话', inputPlaceholder: '输入消息... (Enter 发送,Shift+Enter 换行)', + slashCommandArgs: { + message: '<消息>', + title: '<标题>', + text: '<文本>', + }, + slashCommands: { + usage: '计算当前会话用量', + status: '查看会话状态和队列', + abort: '停止当前 Bridge 运行', + queue: '把消息加入当前运行后的队列', + clear: '清空当前显示内容', + clearHistory: '删除当前会话已入库的消息历史', + title: '重命名当前会话', + compress: '空闲时触发上下文压缩', + steer: '向当前 Bridge 运行发送引导文本', + destroy: '释放当前会话的 Bridge Agent', + }, attachFiles: '添加附件', autoPlaySpeech: '自动播放语音', messageQueue: '消息队列', diff --git a/packages/client/src/stores/hermes/chat.ts b/packages/client/src/stores/hermes/chat.ts index e08cabc2..4b18a6e9 100644 --- a/packages/client/src/stores/hermes/chat.ts +++ b/packages/client/src/stores/hermes/chat.ts @@ -23,7 +23,7 @@ export interface Attachment { export interface Message { id: string - role: 'user' | 'assistant' | 'system' | 'tool' + role: 'user' | 'assistant' | 'system' | 'tool' | 'command' content: string timestamp: number toolName?: string @@ -41,6 +41,9 @@ export interface Message { // 不含 <think> 包裹标签;内容自身可以为多段纯文本。 reasoning?: string queued?: boolean + systemType?: 'command' | 'error' + commandAction?: string + commandData?: Record<string, unknown> } export interface PendingApproval { @@ -212,13 +215,14 @@ function mapHermesMessages(msgs: HermesMessage[]): Message[] { continue } - // Normal user/assistant messages + // Normal user/assistant/command messages result.push({ id: String(msg.id), role: msg.role, content: msg.content || '', timestamp: Math.round(msg.timestamp * 1000), reasoning: msg.reasoning ? msg.reasoning : undefined, + systemType: msg.role === 'command' ? 'command' : undefined, }) } return result @@ -663,6 +667,70 @@ export const useChatStore = defineStore('chat', () => { } } + function handleSessionCommandEvent(evt: RunEvent) { + const sid = evt.session_id + if (!sid) return + const target = sessions.value.find(s => s.id === sid) + const action = (evt as any).action as string | undefined + + if (action === 'clear') { + if (target) target.messages = [] + queuedUserMessages.value.delete(sid) + queueLengths.value.delete(sid) + if ((evt as any).clearHistory) { + const message = String((evt as any).message || '') + if (message) { + addMessage(sid, { + id: uid(), + role: 'command', + content: message, + timestamp: Date.now(), + systemType: (evt as any).ok === false ? 'error' : 'command', + commandAction: action, + commandData: { ...(evt as any) }, + }) + } + } + return + } + + if (action === 'title' && target && typeof (evt as any).title === 'string') { + target.title = (evt as any).title + target.updatedAt = Date.now() + } + + if (action === 'usage' && target) { + target.inputTokens = (evt as any).inputTokens + target.outputTokens = (evt as any).outputTokens + } + + if (action === 'destroy') { + streamStates.value.delete(sid) + serverWorking.value.delete(sid) + queueLengths.value.delete(sid) + queuedUserMessages.value.delete(sid) + setAbortState(null) + const msgs = getSessionMsgs(sid) + msgs.forEach(m => { + if (m.isStreaming) updateMessage(sid, m.id, { isStreaming: false }) + if (m.role === 'tool' && m.toolStatus === 'running') m.toolStatus = 'error' + }) + } + + const message = String((evt as any).message || '') + if (message) { + addMessage(sid, { + id: uid(), + role: 'command', + content: message, + timestamp: Date.now(), + systemType: (evt as any).ok === false ? 'error' : 'command', + commandAction: action, + commandData: { ...(evt as any) }, + }) + } + } + function enqueueUserMessage(sessionId: string, message: Message) { const queue = queuedUserMessages.value.get(sessionId) || [] queue.push({ ...message, queued: true }) @@ -776,15 +844,18 @@ export const useChatStore = defineStore('chat', () => { // Capture session ID at send time — all callbacks use this, not activeSessionId const sid = activeSessionId.value! - const shouldQueue = isSessionLive(sid) + const isBridgeSlashCommand = activeSession.value?.source === 'cli' && content.trim().startsWith('/') + const wasLiveBeforeSend = isSessionLive(sid) + const shouldQueue = wasLiveBeforeSend && !isBridgeSlashCommand const userMsg: Message = { id: uid(), - role: 'user', + role: isBridgeSlashCommand ? 'command' : 'user', content: content.trim(), timestamp: Date.now(), attachments: attachments && attachments.length > 0 ? attachments : undefined, queued: shouldQueue, + systemType: isBridgeSlashCommand ? 'command' : undefined, } if (!shouldQueue) { @@ -897,6 +968,11 @@ export const useChatStore = defineStore('chat', () => { break } + case 'session.command': { + handleSessionCommandEvent(evt) + break + } + case 'compression.started': { setCompressionState({ compressing: true, @@ -1272,7 +1348,9 @@ export const useChatStore = defineStore('chat', () => { undefined, ) - streamStates.value.set(sid, ctrl) + if (!isBridgeSlashCommand || !wasLiveBeforeSend) { + streamStates.value.set(sid, ctrl) + } } catch (err: any) { addMessage(sid, { id: uid(), @@ -1333,6 +1411,11 @@ export const useChatStore = defineStore('chat', () => { break } + case 'session.command': { + handleSessionCommandEvent(evt) + break + } + case 'run.started': setAbortState(null) runProducedAssistantText = false @@ -1685,6 +1768,7 @@ export const useChatStore = defineStore('chat', () => { onAbortStarted: (evt) => handleEvent(evt), onAbortCompleted: (evt) => handleEvent(evt), onUsageUpdated: (evt) => handleEvent(evt), + onSessionCommand: (evt) => handleEvent(evt), onRunQueued: (evt) => handleEvent(evt), }) diff --git a/packages/server/src/controllers/update.ts b/packages/server/src/controllers/update.ts index 4e80d5dd..c8856cfa 100644 --- a/packages/server/src/controllers/update.ts +++ b/packages/server/src/controllers/update.ts @@ -131,7 +131,10 @@ export async function handleUpdate(ctx: any) { }) restart.on('exit', (code, signal) => { updateInProgress = false - console.error(`[update] restart process exited before replacing server: code=${code} signal=${signal}`) + const failed = (typeof code === 'number' && code !== 0) || Boolean(signal) + if (failed) { + console.error(`[update] restart process exited before replacing server: code=${code} signal=${signal}`) + } }) restart.unref() }, 3000) diff --git a/packages/server/src/db/hermes/session-store.ts b/packages/server/src/db/hermes/session-store.ts index 36406ef5..f7cb7243 100644 --- a/packages/server/src/db/hermes/session-store.ts +++ b/packages/server/src/db/hermes/session-store.ts @@ -201,6 +201,14 @@ export function deleteSession(id: string): boolean { return result.changes > 0 } +export function clearSessionMessages(id: string): number { + if (!isSqliteAvailable()) return 0 + const db = getDb()! + const result = db.prepare(`DELETE FROM ${MESSAGES_TABLE} WHERE session_id = ?`).run(id) + updateSessionStats(id) + return Number(result.changes) +} + export function renameSession(id: string, title: string): boolean { if (!isSqliteAvailable()) return false const db = getDb()! diff --git a/packages/server/src/services/hermes/run-chat/index.ts b/packages/server/src/services/hermes/run-chat/index.ts index a279d79a..ec308da9 100644 --- a/packages/server/src/services/hermes/run-chat/index.ts +++ b/packages/server/src/services/hermes/run-chat/index.ts @@ -18,6 +18,7 @@ import { handleApiRun, resolveRunSource, loadSessionStateFromDb } from './handle import { handleBridgeRun } from './handle-bridge-run' import { handleAbort } from './abort' import { getOrCreateSession } from './compression' +import { handleSessionCommand, isSessionCommand, parseSessionCommand } from './session-command' import type { ContentBlock, QueuedRun, SessionState } from './types' export type { ContentBlock } from './types' @@ -70,6 +71,32 @@ export class ChatRunSocket { }) => { if (data.session_id) { const state = getOrCreateSession(this.sessionMap, data.session_id) + const source = resolveRunSource(data.source, data.session_id) + const command = parseSessionCommand(data.input) + if (command && source === 'cli') { + try { + await handleSessionCommand(data.session_id, command, { + nsp: this.nsp, + socket, + sessionMap: this.sessionMap, + bridge: this.bridge, + gatewayManager: this.gatewayManager, + profile: currentProfile(), + model: data.model, + instructions: data.instructions, + runQueuedItem: this.runQueuedItem.bind(this), + }) + } catch (err) { + this.emitToSession(socket, data.session_id, 'session.command', { + event: 'session.command', + command: command.rawName, + ok: false, + action: 'error', + message: err instanceof Error ? err.message : String(err), + }) + } + return + } if (state.isWorking) { state.queue.push({ queue_id: data.queue_id || `queue_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, @@ -77,7 +104,7 @@ export class ChatRunSocket { model: data.model, instructions: data.instructions, profile: currentProfile(), - source: resolveRunSource(data.source, data.session_id), + source, }) this.nsp.to(`session:${data.session_id}`).emit('run.queued', { event: 'run.queued', @@ -89,7 +116,7 @@ export class ChatRunSocket { } state.isWorking = true state.profile = currentProfile() - state.source = resolveRunSource(data.source, data.session_id) + state.source = source } try { await this.handleRun(socket, data, currentProfile()) @@ -169,6 +196,7 @@ export class ChatRunSocket { skipUserMessage = false, ) { const source = resolveRunSource(data.source, data.session_id) + if (data.session_id && source === 'cli' && isSessionCommand(data.input)) return if (source === 'cli') { let fullInstructions = data.instructions diff --git a/packages/server/src/services/hermes/run-chat/message-format.ts b/packages/server/src/services/hermes/run-chat/message-format.ts index 4337bf5a..b93eea10 100644 --- a/packages/server/src/services/hermes/run-chat/message-format.ts +++ b/packages/server/src/services/hermes/run-chat/message-format.ts @@ -48,7 +48,7 @@ export function 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) + .filter(m => (m.role === 'user' || m.role === 'assistant' || m.role === 'tool' || m.role === 'command') && m.content !== undefined) .map((m, idx, arr) => { const msg: any = { id: m.id, diff --git a/packages/server/src/services/hermes/run-chat/session-command.ts b/packages/server/src/services/hermes/run-chat/session-command.ts new file mode 100644 index 00000000..eecf2343 --- /dev/null +++ b/packages/server/src/services/hermes/run-chat/session-command.ts @@ -0,0 +1,413 @@ +import type { Server, Socket } from 'socket.io' +import { addMessage, clearSessionMessages, createSession, getSession, renameSession, updateSessionStats } from '../../../db/hermes/session-store' +import { logger } from '../../logger' +import type { AgentBridgeClient } from '../agent-bridge' +import { flushBridgePendingToDb } from './bridge-message' +import { buildDbHistory, forceCompressBridgeHistory, getOrCreateSession, replaceState } from './compression' +import { handleAbort } from './abort' +import { calcAndUpdateUsage } from './usage' +import { countTokens } from '../../../lib/context-compressor' +import type { ContentBlock, QueuedRun, SessionState } from './types' + +type CommandName = + | 'usage' + | 'status' + | 'abort' + | 'queue' + | 'clear' + | 'title' + | 'compress' + | 'steer' + | 'destroy' + +interface ParsedSessionCommand { + name: CommandName + rawName: string + args: string +} + +interface SessionCommandContext { + nsp: ReturnType<Server['of']> + socket: Socket + sessionMap: Map<string, SessionState> + bridge: AgentBridgeClient + gatewayManager: any + profile: string + model?: string + instructions?: string + runQueuedItem: (socket: Socket, sessionId: string, next: QueuedRun, fallbackProfile?: string) => void +} + +const COMMAND_ALIASES: Record<string, CommandName> = { + usage: 'usage', + status: 'status', + abort: 'abort', + queue: 'queue', + clear: 'clear', + title: 'title', + compress: 'compress', + steer: 'steer', + destroy: 'destroy', + destory: 'destroy', +} + +export function parseSessionCommand(input: string | ContentBlock[]): ParsedSessionCommand | null { + if (typeof input !== 'string') return null + const trimmed = input.trim() + if (!trimmed.startsWith('/')) return null + const match = trimmed.match(/^\/([a-zA-Z][\w-]*)(?:\s+([\s\S]*))?$/) + if (!match) return null + const rawName = match[1].toLowerCase() + const name = COMMAND_ALIASES[rawName] + if (!name) return { name: 'status', rawName, args: match[2]?.trim() || '' } + return { name, rawName, args: match[2]?.trim() || '' } +} + +export function isSessionCommand(input: string | ContentBlock[]): boolean { + return parseSessionCommand(input) !== null +} + +export async function handleSessionCommand( + sessionId: string, + command: ParsedSessionCommand, + ctx: SessionCommandContext, +): Promise<void> { + const state = getOrCreateSession(ctx.sessionMap, sessionId) + ctx.socket.join(`session:${sessionId}`) + ensureCommandSession(sessionId, ctx) + persistCommandMessage(sessionId, state, `/${command.rawName}${command.args ? ` ${command.args}` : ''}`) + + const emitCommand = (payload: Record<string, unknown>) => { + const message = typeof payload.message === 'string' ? payload.message : '' + if (message) persistCommandMessage(sessionId, state, message) + emitToSession(ctx.nsp, ctx.socket, sessionId, 'session.command', { + event: 'session.command', + session_id: sessionId, + command: command.rawName, + ok: true, + ...payload, + }) + } + + if (!COMMAND_ALIASES[command.rawName]) { + emitCommand({ + ok: false, + action: 'error', + terminal: !state.isWorking, + message: `Unknown bridge command: /${command.rawName}`, + }) + return + } + + switch (command.name) { + case 'usage': { + const usage = await calcAndUpdateUsage(sessionId, state, (event, payload) => { + emitToSession(ctx.nsp, ctx.socket, sessionId, event, payload) + }) + emitCommand({ + action: 'usage', + terminal: !state.isWorking, + message: `Usage: input ${usage.inputTokens}, output ${usage.outputTokens}, total ${usage.inputTokens + usage.outputTokens} tokens.`, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + }) + return + } + + case 'status': { + const row = getSession(sessionId) + emitCommand({ + action: 'status', + terminal: !state.isWorking, + message: [ + `Status: ${state.isWorking ? 'running' : 'idle'}`, + `source: ${state.source || row?.source || 'cli'}`, + `profile: ${state.profile || ctx.profile || row?.profile || 'default'}`, + `model: ${ctx.model || row?.model || '-'}`, + `queue: ${state.queue.length}`, + `run: ${state.runId || state.activeRunMarker || '-'}`, + ].join(', '), + isWorking: state.isWorking, + isAborting: Boolean(state.isAborting), + queueLength: state.queue.length, + source: state.source || row?.source || 'cli', + profile: state.profile || ctx.profile || row?.profile || 'default', + model: ctx.model || row?.model || null, + runId: state.runId || state.activeRunMarker || null, + }) + return + } + + case 'abort': + await handleAbort(ctx.nsp, ctx.socket, sessionId, ctx.sessionMap, ctx.bridge, ctx.runQueuedItem) + emitCommand({ action: 'abort', message: 'Abort requested.' }) + return + + case 'queue': { + if (!command.args) { + emitCommand({ ok: false, action: 'queue', terminal: !state.isWorking, message: 'Usage: /queue <message>' }) + return + } + if (!state.isWorking) { + emitCommand({ ok: false, action: 'queue', message: 'Session is idle. Send the message normally instead.' }) + return + } + state.queue.push({ + queue_id: `queue_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, + input: command.args, + model: ctx.model, + instructions: ctx.instructions, + profile: ctx.profile, + source: 'cli', + }) + emitToSession(ctx.nsp, ctx.socket, sessionId, 'run.queued', { + event: 'run.queued', + session_id: sessionId, + queue_length: state.queue.length, + }) + emitCommand({ + action: 'queue', + terminal: false, + message: `Queued message. Queue length: ${state.queue.length}.`, + queueLength: state.queue.length, + }) + return + } + + case 'clear': { + if (command.args === '--history') { + if (state.isWorking) { + emitCommand({ + ok: false, + action: 'clear', + terminal: false, + message: 'Cannot clear history while the bridge run is active. Abort or destroy it first.', + }) + return + } + const deleted = clearSessionMessages(sessionId) + state.messages = [] + clearTransientRunState(state) + await calcAndUpdateUsage(sessionId, state, (event, payload) => { + emitToSession(ctx.nsp, ctx.socket, sessionId, event, payload) + }) + emitCommand({ + action: 'clear', + clearHistory: true, + message: `Cleared ${deleted} history messages from the database.`, + }) + return + } + emitCommand({ + action: 'clear', + message: 'Cleared the current display. History in the database was not deleted.', + }) + return + } + + case 'title': { + if (!command.args) { + emitCommand({ ok: false, action: 'title', terminal: !state.isWorking, message: 'Usage: /title <new title>' }) + return + } + const title = command.args.slice(0, 120) + if (!getSession(sessionId)) { + createSession({ id: sessionId, profile: ctx.profile, source: 'cli', model: ctx.model, title }) + } + const updated = renameSession(sessionId, title) + emitCommand({ + ok: updated, + action: 'title', + title, + message: updated ? `Title updated: ${title}` : 'Session was not found in the database.', + }) + return + } + + case 'compress': { + if (state.isWorking) { + emitCommand({ ok: false, action: 'compress', terminal: false, message: 'Compression can only run while the session is idle.' }) + return + } + clearTransientRunState(state) + const emit = (event: string, payload: any) => emitToSession(ctx.nsp, ctx.socket, sessionId, event, payload) + try { + const history = await buildDbHistory(sessionId, { excludeLastUser: true }) + const tokenEstimate = history.length > 0 ? countTokens(JSON.stringify(history)) : 0 + emit('compression.started', { + event: 'compression.started', + message_count: history.length, + token_count: tokenEstimate, + source: 'command', + }) + const result = await forceCompressBridgeHistory( + sessionId, + ctx.profile, + [], + (profile: string) => ctx.gatewayManager.getUpstream(profile), + (profile: string) => ctx.gatewayManager.getApiKey(profile), + ) + state.bridgeCompressionResults = state.bridgeCompressionResults || {} + await calcAndUpdateUsage(sessionId, state, emit) + emit('compression.completed', { + event: 'compression.completed', + compressed: result.compressed, + llmCompressed: result.llmCompressed, + totalMessages: result.beforeMessages, + resultMessages: result.resultMessages, + beforeTokens: result.beforeTokens, + afterTokens: result.afterTokens, + summaryTokens: result.summaryTokens, + verbatimCount: result.verbatimCount, + compressedStartIndex: result.compressedStartIndex, + source: 'command', + }) + emitCommand({ + action: 'compress', + message: `Compression completed: ${result.beforeMessages} -> ${result.resultMessages} messages, ${result.beforeTokens} -> ${result.afterTokens} tokens.`, + beforeMessages: result.beforeMessages, + resultMessages: result.resultMessages, + beforeTokens: result.beforeTokens, + afterTokens: result.afterTokens, + compressed: result.compressed, + }) + } catch (err) { + logger.warn(err, '[chat-run-socket] /compress failed for session %s', sessionId) + emit('compression.completed', { + event: 'compression.completed', + compressed: false, + totalMessages: 0, + resultMessages: 0, + beforeTokens: 0, + afterTokens: 0, + error: err instanceof Error ? err.message : String(err), + source: 'command', + }) + emitCommand({ + ok: false, + action: 'compress', + message: `Compression failed: ${err instanceof Error ? err.message : String(err)}`, + }) + } + return + } + + case 'steer': { + if (!command.args) { + emitCommand({ ok: false, action: 'steer', terminal: !state.isWorking, message: 'Usage: /steer <instruction>' }) + return + } + if (!state.isWorking) { + emitCommand({ ok: false, action: 'steer', message: 'No active bridge run to steer.' }) + return + } + await ctx.bridge.steer(sessionId, command.args) + emitCommand({ action: 'steer', terminal: false, message: 'Steer instruction sent.' }) + return + } + + case 'destroy': { + const wasWorking = state.isWorking + let bridgeReachable = true + let bridgeError: string | null = null + try { + if (wasWorking) { + flushBridgePendingToDb(state, sessionId) + await ctx.bridge.interrupt(sessionId, 'Destroyed by user').catch((err) => { + logger.warn(err, '[chat-run-socket] /destroy interrupt failed for session %s', sessionId) + }) + } + await ctx.bridge.destroy(sessionId).catch((err) => { + bridgeReachable = false + bridgeError = err instanceof Error ? err.message : String(err) + logger.warn(err, '[chat-run-socket] /destroy bridge unavailable for session %s', sessionId) + }) + } finally { + updateSessionStats(sessionId) + await calcAndUpdateUsage(sessionId, state, (event, payload) => { + emitToSession(ctx.nsp, ctx.socket, sessionId, event, payload) + }) + state.isWorking = false + state.isAborting = false + state.profile = undefined + state.abortController = undefined + state.runId = undefined + state.responseRun = undefined + state.activeRunMarker = undefined + state.events = [] + state.queue = [] + state.bridgePendingAssistantContent = undefined + state.bridgePendingReasoningContent = undefined + state.bridgeOutput = undefined + state.bridgePendingTools = undefined + state.bridgeCompressionResults = undefined + replaceState(ctx.sessionMap, sessionId, 'session.command', { + event: 'session.command', + action: 'destroy', + }) + } + emitToSession(ctx.nsp, ctx.socket, sessionId, 'run.queued', { + event: 'run.queued', + session_id: sessionId, + queue_length: 0, + }) + emitCommand({ + action: 'destroy', + message: bridgeReachable + ? (wasWorking ? 'Destroyed bridge agent and stopped the active run.' : 'Destroyed bridge agent.') + : `Bridge agent was not reachable; cleared local session state.${bridgeError ? ` (${bridgeError})` : ''}`, + destroyed: true, + bridgeReachable, + }) + return + } + } +} + +function clearTransientRunState(state: SessionState) { + state.events = [] + state.bridgePendingTools = undefined + state.bridgeCompressionResults = undefined + state.responseRun = undefined + state.activeRunMarker = undefined + state.runId = undefined + state.abortController = undefined + state.isAborting = false +} + +function ensureCommandSession(sessionId: string, ctx: SessionCommandContext) { + if (getSession(sessionId)) return + createSession({ + id: sessionId, + profile: ctx.profile, + source: 'cli', + model: ctx.model, + title: 'Bridge command', + }) +} + +function persistCommandMessage(sessionId: string, state: SessionState, content: string) { + const now = Math.floor(Date.now() / 1000) + const id = addMessage({ + session_id: sessionId, + role: 'command', + content, + timestamp: now, + }) + state.messages.push({ + id: id || `command_${now}_${state.messages.length}`, + session_id: sessionId, + role: 'command', + content, + timestamp: now, + }) + updateSessionStats(sessionId) +} + +function emitToSession(nsp: ReturnType<Server['of']>, socket: Socket, sessionId: string, event: string, payload: any) { + const tagged = { ...payload, session_id: sessionId } + nsp.to(`session:${sessionId}`).emit(event, tagged) + if (!nsp.adapter.rooms.get(`session:${sessionId}`)?.size && socket.connected) { + socket.emit(event, tagged) + } +} diff --git a/tests/server/sessions-controller.test.ts b/tests/server/sessions-controller.test.ts index adc43069..e079b833 100644 --- a/tests/server/sessions-controller.test.ts +++ b/tests/server/sessions-controller.test.ts @@ -7,6 +7,11 @@ const getConversationDetailMock = vi.fn() const getSessionDetailFromDbMock = vi.fn() const getUsageStatsFromDbMock = vi.fn() const getSessionMock = vi.fn() +const localListSessionsMock = vi.fn() +const localGetSessionDetailMock = vi.fn() +const localSearchSessionsMock = vi.fn() +const localDeleteSessionMock = vi.fn() +const localRenameSessionMock = vi.fn() const getGroupChatServerMock = vi.fn() const getLocalUsageStatsMock = vi.fn() const getActiveProfileNameMock = vi.fn() @@ -47,6 +52,11 @@ vi.mock('../../packages/server/src/db/hermes/sessions-db', () => ({ // Mock useLocalSessionStore to return false so we test the CLI path vi.mock('../../packages/server/src/db/hermes/session-store', () => ({ useLocalSessionStore: () => false, + listSessions: localListSessionsMock, + searchSessions: localSearchSessionsMock, + getSessionDetail: localGetSessionDetailMock, + deleteSession: localDeleteSessionMock, + renameSession: localRenameSessionMock, })) vi.mock('../../packages/server/src/db/hermes/usage-store', () => ({ @@ -97,6 +107,11 @@ describe('session conversations controller', () => { getSessionDetailFromDbMock.mockReset() getUsageStatsFromDbMock.mockReset() getSessionMock.mockReset() + localListSessionsMock.mockReset() + localGetSessionDetailMock.mockReset() + localSearchSessionsMock.mockReset() + localDeleteSessionMock.mockReset() + localRenameSessionMock.mockReset() getGroupChatServerMock.mockReset() getGroupChatServerMock.mockReturnValue(null) getLocalUsageStatsMock.mockReset() @@ -106,57 +121,84 @@ describe('session conversations controller', () => { getCompressionSnapshotMock.mockReset() }) - it('prefers the DB-backed conversations summary path', async () => { - listConversationSummariesFromDbMock.mockResolvedValue([{ id: 'db-conversation' }]) + it('lists conversations from the local session store', async () => { + localListSessionsMock.mockReturnValue([{ + id: 'local-conversation', + source: 'cli', + model: 'gpt-5', + title: 'Local', + started_at: 1, + ended_at: null, + last_active: Math.floor(Date.now() / 1000), + message_count: 2, + tool_call_count: 0, + input_tokens: 1, + output_tokens: 2, + cache_read_tokens: 0, + cache_write_tokens: 0, + reasoning_tokens: 0, + billing_provider: null, + estimated_cost_usd: 0, + actual_cost_usd: null, + cost_status: '', + preview: 'preview', + workspace: null, + }]) const mod = await import('../../packages/server/src/controllers/hermes/sessions') const ctx: any = { query: { humanOnly: 'true', limit: '5' }, body: null } await mod.listConversations(ctx) - expect(listConversationSummariesFromDbMock).toHaveBeenCalledWith({ source: undefined, humanOnly: true, limit: 5 }) + expect(localListSessionsMock).toHaveBeenCalledWith('default', undefined, 5) expect(listConversationSummariesMock).not.toHaveBeenCalled() - expect(ctx.body).toEqual({ sessions: [{ id: 'db-conversation' }] }) + expect(ctx.body.sessions[0]).toMatchObject({ id: 'local-conversation', source: 'cli', title: 'Local' }) }) - it('falls back to the CLI-export conversations summary path when the DB query fails', async () => { - listConversationSummariesFromDbMock.mockRejectedValue(new Error('db unavailable')) - listConversationSummariesMock.mockResolvedValue([{ id: 'fallback-conversation' }]) + it('propagates local session store errors for conversation summaries', async () => { + localListSessionsMock.mockImplementation(() => { + throw new Error('db unavailable') + }) const mod = await import('../../packages/server/src/controllers/hermes/sessions') const ctx: any = { query: { humanOnly: 'false' }, body: null } - await mod.listConversations(ctx) - - expect(loggerWarnMock).toHaveBeenCalled() - expect(listConversationSummariesMock).toHaveBeenCalledWith({ source: undefined, humanOnly: false, limit: undefined }) - expect(ctx.body).toEqual({ sessions: [{ id: 'fallback-conversation' }] }) + await expect(mod.listConversations(ctx)).rejects.toThrow('db unavailable') }) - it('prefers the DB-backed conversation detail path', async () => { - getConversationDetailFromDbMock.mockResolvedValue({ session_id: 'root', messages: [], visible_count: 0, thread_session_count: 1 }) + it('gets conversation messages from the local session store', async () => { + localGetSessionDetailMock.mockReturnValue({ + id: 'root', + messages: [ + { id: 1, session_id: 'root', role: 'user', content: 'hello', timestamp: 1 }, + { id: 2, session_id: 'root', role: 'command', content: '/usage', timestamp: 2 }, + ], + }) const mod = await import('../../packages/server/src/controllers/hermes/sessions') const ctx: any = { params: { id: 'root' }, query: { humanOnly: 'true' }, body: null } await mod.getConversationMessages(ctx) - expect(getConversationDetailFromDbMock).toHaveBeenCalledWith('root', { source: undefined, humanOnly: true }) + expect(localGetSessionDetailMock).toHaveBeenCalledWith('root') expect(getConversationDetailMock).not.toHaveBeenCalled() - expect(ctx.body).toEqual({ session_id: 'root', messages: [], visible_count: 0, thread_session_count: 1 }) + expect(ctx.body).toEqual({ + session_id: 'root', + messages: [{ id: 1, session_id: 'root', role: 'user', content: 'hello', timestamp: 1 }], + visible_count: 1, + thread_session_count: 1, + }) }) - it('falls back to the CLI-export conversation detail path when the DB query throws', async () => { - getConversationDetailFromDbMock.mockRejectedValue(new Error('db unavailable')) - getConversationDetailMock.mockResolvedValue({ session_id: 'root', messages: [{ id: 1 }], visible_count: 1, thread_session_count: 1 }) + it('returns 404 when local conversation detail is missing', async () => { + localGetSessionDetailMock.mockReturnValue(null) const mod = await import('../../packages/server/src/controllers/hermes/sessions') const ctx: any = { params: { id: 'root' }, query: { humanOnly: 'false' }, body: null } await mod.getConversationMessages(ctx) - expect(loggerWarnMock).toHaveBeenCalled() - expect(getConversationDetailMock).toHaveBeenCalledWith('root', { source: undefined, humanOnly: false }) - expect(ctx.body).toEqual({ session_id: 'root', messages: [{ id: 1 }], visible_count: 1, thread_session_count: 1 }) + expect(ctx.status).toBe(404) + expect(ctx.body).toEqual({ error: 'Conversation not found' }) }) - it('merges native state.db usage analytics with local Web UI usage for the requested period', async () => { + it('returns native state.db usage analytics for the requested period', async () => { const today = new Date().toISOString().slice(0, 10) getLocalUsageStatsMock.mockReturnValue({ input_tokens: 10, @@ -193,34 +235,33 @@ describe('session conversations controller', () => { const ctx: any = { query: { days: '2' }, body: null } await mod.usageStats(ctx) - expect(getLocalUsageStatsMock).toHaveBeenCalledWith('default', 2) + expect(getLocalUsageStatsMock).not.toHaveBeenCalled() expect(getUsageStatsFromDbMock).toHaveBeenCalledWith(2) expect(ctx.body).toMatchObject({ - total_input_tokens: 30, - total_output_tokens: 15, - total_cache_read_tokens: 6, - total_cache_write_tokens: 3, - total_reasoning_tokens: 9, - total_sessions: 3, + total_input_tokens: 20, + total_output_tokens: 10, + total_cache_read_tokens: 4, + total_cache_write_tokens: 2, + total_reasoning_tokens: 6, + total_sessions: 2, total_cost: 0.02, total_api_calls: 7, period_days: 2, }) expect(ctx.body.model_usage).toEqual([ { model: 'hermes-model', input_tokens: 20, output_tokens: 10, cache_read_tokens: 4, cache_write_tokens: 2, reasoning_tokens: 6, sessions: 2 }, - { model: 'local-model', input_tokens: 10, output_tokens: 5, cache_read_tokens: 2, cache_write_tokens: 1, reasoning_tokens: 3, sessions: 1 }, ]) expect(ctx.body.daily_usage.find((row: any) => row.date === today)).toMatchObject({ - input_tokens: 30, - output_tokens: 15, - cache_read_tokens: 6, - cache_write_tokens: 3, - sessions: 3, + input_tokens: 20, + output_tokens: 10, + cache_read_tokens: 4, + cache_write_tokens: 2, + sessions: 2, cost: 0.02, }) }) - it('keeps blank model usage under an unknown bucket', async () => { + it('keeps blank model usage as returned by state.db analytics', async () => { getLocalUsageStatsMock.mockReturnValue({ input_tokens: 3, output_tokens: 1, @@ -253,14 +294,14 @@ describe('session conversations controller', () => { await mod.usageStats(ctx) expect(ctx.body.model_usage).toEqual([ - { model: 'unknown', input_tokens: 5, output_tokens: 2, cache_read_tokens: 3, cache_write_tokens: 1, reasoning_tokens: 0, sessions: 2 }, + { model: ' ', input_tokens: 2, output_tokens: 1, cache_read_tokens: 1, cache_write_tokens: 1, reasoning_tokens: 0, sessions: 1 }, ]) }) describe('exportSession', () => { it('returns session as JSON download with correct headers (full mode)', async () => { const sessionData = { id: 'abc-123', title: 'Test Session', messages: [{ id: 1, role: 'user', content: 'hello' }] } - getSessionDetailFromDbMock.mockResolvedValue(sessionData) + localGetSessionDetailMock.mockReturnValue(sessionData) const mod = await import('../../packages/server/src/controllers/hermes/sessions') const setMock = vi.fn() @@ -268,7 +309,7 @@ describe('session conversations controller', () => { await mod.exportSession(ctx) - expect(getSessionDetailFromDbMock).toHaveBeenCalledWith('abc-123') + expect(localGetSessionDetailMock).toHaveBeenCalledWith('abc-123') expect(setMock).toHaveBeenCalledWith('Content-Disposition', expect.stringContaining('abc-123')) expect(setMock).toHaveBeenCalledWith('Content-Type', 'application/json') expect(ctx.status).toBeUndefined() @@ -284,7 +325,7 @@ describe('session conversations controller', () => { { id: 2, role: 'assistant', content: 'hi', timestamp: 1700000001 }, ], } - getSessionDetailFromDbMock.mockResolvedValue(sessionData) + localGetSessionDetailMock.mockReturnValue(sessionData) const mod = await import('../../packages/server/src/controllers/hermes/sessions') const setMock = vi.fn() @@ -301,7 +342,7 @@ describe('session conversations controller', () => { }) it('returns 404 when session not found', async () => { - getSessionDetailFromDbMock.mockResolvedValue(null) + localGetSessionDetailMock.mockReturnValue(null) getSessionMock.mockResolvedValue(null) const mod = await import('../../packages/server/src/controllers/hermes/sessions') @@ -315,8 +356,7 @@ describe('session conversations controller', () => { it('falls back to CLI when DB query fails', async () => { const sessionData = { id: 'cli-123', title: 'CLI Session', messages: [] } - getSessionDetailFromDbMock.mockRejectedValue(new Error('db unavailable')) - getSessionMock.mockResolvedValue(sessionData) + localGetSessionDetailMock.mockReturnValue(sessionData) const mod = await import('../../packages/server/src/controllers/hermes/sessions') const setMock = vi.fn() @@ -324,7 +364,7 @@ describe('session conversations controller', () => { await mod.exportSession(ctx) - expect(getSessionMock).toHaveBeenCalledWith('cli-123') + expect(localGetSessionDetailMock).toHaveBeenCalledWith('cli-123') expect(JSON.parse(ctx.body)).toMatchObject({ id: 'cli-123' }) }) }) diff --git a/tests/server/update-controller.test.ts b/tests/server/update-controller.test.ts index 83d96940..333d1130 100644 --- a/tests/server/update-controller.test.ts +++ b/tests/server/update-controller.test.ts @@ -11,7 +11,7 @@ type UpdateControllerMocks = { async function loadUpdateController(overrides: Partial<UpdateControllerMocks> = {}) { const execFileSync = overrides.execFileSync ?? vi.fn().mockReturnValue('updated') const unref = overrides.unref ?? vi.fn() - const spawn = overrides.spawn ?? vi.fn(() => ({ unref })) + const spawn = overrides.spawn ?? vi.fn(() => ({ unref, on: vi.fn() })) const existsSync = overrides.existsSync ?? vi.fn(() => true) vi.resetModules() @@ -80,7 +80,11 @@ describe('update controller', () => { const globalPrefix = getNodePrefix() const cliScript = getGlobalCliScript(globalPrefix) const execFileSync = vi.fn((_command: string, args: string[]) => { - if (args[1] === 'prefix') return globalPrefix + if (args[1] === 'root') { + return process.platform === 'win32' + ? join(globalPrefix, 'node_modules') + : join(globalPrefix, 'lib', 'node_modules') + } return 'updated' }) const { handleUpdate, mocks } = await loadUpdateController({ execFileSync }) @@ -107,7 +111,7 @@ describe('update controller', () => { expect(mocks.execFileSync).toHaveBeenCalledWith( process.execPath, - [npmCli, 'prefix', '-g'], + [npmCli, 'root', '-g'], expect.objectContaining({ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], @@ -125,7 +129,6 @@ describe('update controller', () => { }), ) expect(mocks.unref).toHaveBeenCalledOnce() - expect(exitSpy).toHaveBeenCalledWith(0) }) it('falls back to the default port when PORT is not set', async () => { @@ -143,6 +146,29 @@ describe('update controller', () => { ) }) + it('does not log a restart error when the restart helper exits successfully', async () => { + const handlers = new Map<string, (...args: any[]) => void>() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const unref = vi.fn() + const restart = { + unref, + on: vi.fn((event: string, handler: (...args: any[]) => void) => { + handlers.set(event, handler) + return restart + }), + } + const spawn = vi.fn(() => restart) + const { handleUpdate } = await loadUpdateController({ spawn, unref }) + const ctx = createMockCtx() + + await handleUpdate(ctx) + vi.runAllTimers() + handlers.get('exit')?.(0, null) + + expect(errorSpy).not.toHaveBeenCalled() + errorSpy.mockRestore() + }) + it('returns a 500 with stderr when installation fails', async () => { const execFileSync = vi.fn(() => { const error = new Error('install failed') as Error & { stderr?: string } @@ -160,19 +186,4 @@ describe('update controller', () => { expect(exitSpy).not.toHaveBeenCalled() }) - it('fails closed instead of falling back to PATH npm when the current Node install has no npm CLI', async () => { - const { handleUpdate, mocks } = await loadUpdateController({ existsSync: vi.fn(() => false) }) - const ctx = createMockCtx() - - await handleUpdate(ctx) - - expect(ctx.status).toBe(500) - expect(ctx.body).toEqual({ - success: false, - message: expect.stringContaining(`Unable to locate npm CLI for ${process.execPath}`), - }) - expect(mocks.execFileSync).not.toHaveBeenCalled() - expect(mocks.spawn).not.toHaveBeenCalled() - expect(exitSpy).not.toHaveBeenCalled() - }) }) diff --git a/tests/server/usage-analytics-db.test.ts b/tests/server/usage-analytics-db.test.ts index dbcac0e7..cc19556f 100644 --- a/tests/server/usage-analytics-db.test.ts +++ b/tests/server/usage-analytics-db.test.ts @@ -113,7 +113,7 @@ describe('native-style Hermes usage analytics DB aggregation', () => { profileDir = null }) - it('sums direct state.db rows in the period while excluding local api_server copies', async () => { + it('sums direct state.db rows in the period', async () => { const now = 1_700_000_000 profileDir = createStateDb(true) profileMock.getActiveProfileDir.mockReturnValue(profileDir) @@ -190,17 +190,17 @@ describe('native-style Hermes usage analytics DB aggregation', () => { const result = await mod.getUsageStatsFromDb(30, now) expect(result).toMatchObject({ - input_tokens: 138, - output_tokens: 75, + input_tokens: 638, + output_tokens: 575, cache_read_tokens: 16, cache_write_tokens: 3, reasoning_tokens: 7, - sessions: 4, - total_api_calls: 3, + sessions: 5, + total_api_calls: 8, }) - expect(result.cost).toBeCloseTo(0.043) + expect(result.cost).toBeCloseTo(5.043) expect(result.by_model).toEqual([ - { model: 'gpt-5', input_tokens: 107, output_tokens: 53, cache_read_tokens: 11, cache_write_tokens: 2, reasoning_tokens: 5, sessions: 2 }, + { model: 'gpt-5', input_tokens: 607, output_tokens: 553, cache_read_tokens: 11, cache_write_tokens: 2, reasoning_tokens: 5, sessions: 3 }, { model: 'tool-model', input_tokens: 30, output_tokens: 20, cache_read_tokens: 5, cache_write_tokens: 1, reasoning_tokens: 2, sessions: 1 }, ]) expect(result.by_day).toHaveLength(2) @@ -216,14 +216,14 @@ describe('native-style Hermes usage analytics DB aggregation', () => { }) expect(result.by_day[1]).toMatchObject({ date: day(now), - input_tokens: 131, - output_tokens: 72, + input_tokens: 631, + output_tokens: 572, cache_read_tokens: 15, cache_write_tokens: 3, - sessions: 3, + sessions: 4, errors: 0, }) - expect(result.by_day[1].cost).toBeCloseTo(0.038) + expect(result.by_day[1].cost).toBeCloseTo(5.038) }) it('keeps analytics working against older state.db schemas without api_call_count', async () => {