diff --git a/packages/client/src/api/hermes/chat.ts b/packages/client/src/api/hermes/chat.ts index 3ebab2e..a9ef556 100644 --- a/packages/client/src/api/hermes/chat.ts +++ b/packages/client/src/api/hermes/chat.ts @@ -1,5 +1,4 @@ import { io, type Socket } from 'socket.io-client' -import type { ApprovalChoice } from '../../utils/approval-commands' import { request, getBaseUrlValue, getApiKey } from '../client' export type ContentBlock = @@ -40,14 +39,6 @@ export interface RunEvent { /** Final response text on `run.completed`. May be empty/null if the agent * silently swallowed an upstream error — see chat store for fallback. */ output?: string | null - command?: string - description?: string - pattern_key?: string - pattern_keys?: string[] - choices?: ApprovalChoice[] - resolved?: number - choice?: ApprovalChoice - all?: boolean usage?: { input_tokens: number output_tokens: number @@ -84,8 +75,6 @@ const sessionEventHandlers = new Map void onAbortStarted: (event: RunEvent) => void onAbortCompleted: (event: RunEvent) => void - onApprovalRequest: (event: RunEvent) => void - onApprovalResponded: (event: RunEvent) => void onUsageUpdated: (event: RunEvent) => void onRunQueued?: (event: RunEvent) => void }>() @@ -286,32 +275,6 @@ function globalAbortCompletedHandler(event: RunEvent): void { sessionEventHandlers.delete(sid) } -/** - * Global approval.request event handler - */ -function globalApprovalRequestHandler(event: RunEvent): void { - const sid = event.session_id - if (!sid) return - - const handlers = sessionEventHandlers.get(sid) - if (handlers?.onApprovalRequest) { - handlers.onApprovalRequest(event) - } -} - -/** - * Global approval.responded event handler - */ -function globalApprovalRespondedHandler(event: RunEvent): void { - const sid = event.session_id - if (!sid) return - - const handlers = sessionEventHandlers.get(sid) - if (handlers?.onApprovalResponded) { - handlers.onApprovalResponded(event) - } -} - /** * Global usage.updated event handler */ @@ -347,8 +310,6 @@ export function registerSessionHandlers( onCompressionCompleted: (event: RunEvent) => void onAbortStarted: (event: RunEvent) => void onAbortCompleted: (event: RunEvent) => void - onApprovalRequest: (event: RunEvent) => void - onApprovalResponded: (event: RunEvent) => void onUsageUpdated: (event: RunEvent) => void onRunQueued?: (event: RunEvent) => void } @@ -431,11 +392,6 @@ export function connectChatRun(): Socket { chatRunSocket.on('abort.started', globalAbortStartedHandler) chatRunSocket.on('abort.completed', globalAbortCompletedHandler) - // Approval events - chatRunSocket.on('approval.requested', globalApprovalRequestHandler) - chatRunSocket.on('approval.request', globalApprovalRequestHandler) - chatRunSocket.on('approval.responded', globalApprovalRespondedHandler) - // Usage events chatRunSocket.on('usage.updated', globalUsageUpdatedHandler) @@ -563,14 +519,6 @@ export function startRunViaSocket( closed = true onDone() }, - onApprovalRequest: (evt: RunEvent) => { - if (closed) return - onEvent(evt) - }, - onApprovalResponded: (evt: RunEvent) => { - if (closed) return - onEvent(evt) - }, onUsageUpdated: (evt: RunEvent) => { if (closed) return onEvent(evt) @@ -596,16 +544,6 @@ export function startRunViaSocket( } } -export function submitApprovalViaSocket( - sessionId: string, - choice: ApprovalChoice, - all = false, -): Socket { - const socket = connectChatRun() - socket.emit('approval.respond', { session_id: sessionId, choice, all }) - return socket -} - export async function fetchModels(): Promise<{ data: Array<{ id: string }> }> { return request('/api/hermes/v1/models') } diff --git a/packages/client/src/components/hermes/chat/ChatInput.vue b/packages/client/src/components/hermes/chat/ChatInput.vue index 543fe1e..3afb724 100644 --- a/packages/client/src/components/hermes/chat/ChatInput.vue +++ b/packages/client/src/components/hermes/chat/ChatInput.vue @@ -195,7 +195,7 @@ function handleDrop(e: DragEvent) { function handleSend() { const text = inputText.value.trim() - if (!canSend.value) return + if (!text && attachments.value.length === 0) return chatStore.sendMessage(text, attachments.value.length > 0 ? attachments.value : undefined) inputText.value = '' diff --git a/packages/client/src/components/hermes/chat/MessageItem.vue b/packages/client/src/components/hermes/chat/MessageItem.vue index cc7cc04..b863801 100644 --- a/packages/client/src/components/hermes/chat/MessageItem.vue +++ b/packages/client/src/components/hermes/chat/MessageItem.vue @@ -595,15 +595,11 @@ onBeforeUnmount(() => { - + - diff --git a/packages/client/src/stores/hermes/chat.ts b/packages/client/src/stores/hermes/chat.ts index 39d63fd..f064435 100644 --- a/packages/client/src/stores/hermes/chat.ts +++ b/packages/client/src/stores/hermes/chat.ts @@ -1,4 +1,4 @@ -import { startRunViaSocket, resumeSession, registerSessionHandlers, unregisterSessionHandlers, getChatRunSocket, submitApprovalViaSocket, type RunEvent, type ContentBlock as ContentBlockImport } from '@/api/hermes/chat' +import { startRunViaSocket, resumeSession, registerSessionHandlers, unregisterSessionHandlers, getChatRunSocket, type RunEvent, type ContentBlock as ContentBlockImport } from '@/api/hermes/chat' import { deleteSession as deleteSessionApi, fetchSession, fetchSessions, type HermesMessage, type SessionSummary } from '@/api/hermes/sessions' import { getApiKey } from '@/api/client' import { defineStore } from 'pinia' @@ -8,7 +8,6 @@ import { useProfilesStore } from './profiles' import { useSettingsStore } from './settings' import { primeCompletionSound, playCompletionSound } from '@/utils/completion-sound' import { detectThinkingBoundary } from '@/utils/thinking-parser' -import { parseApprovalCommand } from '@/utils/approval-commands' // Re-export ContentBlock for convenience export type ContentBlock = ContentBlockImport @@ -497,10 +496,6 @@ export const useChatStore = defineStore('chat', () => { compressed: e.compressed ?? false, error: e.error, }) - } else if (e.event === 'approval.requested' || e.event === 'approval.request') { - addApprovalRequestMessage(sessionId, { ...e, event: 'approval.request' }) - } else if (e.event === 'approval.responded') { - addApprovalResponseMessage(sessionId, e) } else if (e.event === 'abort.started') { setAbortState({ aborting: true, synced: null }) } else if (e.event === 'abort.completed') { @@ -633,88 +628,6 @@ export const useChatStore = defineStore('chat', () => { target.updatedAt = Date.now() } - function addApprovalRequestMessage(sessionId: string, evt: RunEvent) { - const msgs = getSessionMsgs(sessionId) - const last = msgs[msgs.length - 1] - if (last?.isStreaming) { - updateMessage(sessionId, last.id, { isStreaming: false }) - } - - const command = (evt.command || '').trim() - const description = (evt.description || '').trim() - const patterns = Array.from(new Set([ - ...((evt as any).pattern_keys || []), - evt.pattern_key, - ].filter(Boolean))) as string[] - const patternText = patterns.join(', ') - const choices = Array.isArray(evt.choices) && evt.choices.length > 0 - ? evt.choices - : ['once', 'session', 'always', 'deny'] - const alreadyShown = msgs.some(m => - m.role === 'system' && - m.content.includes('Approval required') && - (!command || m.content.includes(command)) && - (!description || m.content.includes(description)) && - (!patternText || m.content.includes(patternText)), - ) - if (alreadyShown) return - - const details = [ - description ? `Reason: ${description}` : '', - patternText ? `Patterns: ${patternText}` : '', - choices.length ? `Choices: ${choices.join(', ')}` : '', - command ? `Command:\n\`\`\`\n${command}\n\`\`\`` : '', - ].filter(Boolean).join('\n\n') - - addMessage(sessionId, { - id: uid(), - role: 'system', - content: `Approval required\n\n${details || 'The agent is waiting for your approval.'}\n\nReply with /approve to approve once, /approve session, /approve always, or /deny. Use /approve all or /deny all only to resolve all currently pending approval requests for this run.`, - timestamp: Date.now(), - }) - } - - function addApprovalResponseMessage(sessionId: string, evt: RunEvent) { - const choiceLabel = (() => { - switch (evt.choice) { - case 'once': return 'approved once' - case 'session': return 'approved for this session' - case 'always': return 'always allowed' - case 'deny': return 'denied' - default: return 'resolved' - } - })() - const resolved = typeof evt.resolved === 'number' ? evt.resolved : 0 - const error = (evt as any).error - const content = error - ? `Approval response failed: ${error}` - : resolved > 0 - ? `Approval ${choiceLabel}. Resolved ${resolved} pending request${resolved === 1 ? '' : 's'}.` - : `No pending approval request was found to ${evt.choice || 'resolve'}.` - const msgs = getSessionMsgs(sessionId) - const alreadyShown = msgs.some(m => m.role === 'system' && m.content === content) - if (alreadyShown) return - addMessage(sessionId, { - id: uid(), - role: 'system', - content, - timestamp: Date.now(), - }) - } - - function submitApprovalCommand(sessionId: string, content: string): boolean { - const approval = parseApprovalCommand(content) - if (!approval) return false - addMessage(sessionId, { - id: uid(), - role: 'user', - content: content.trim(), - timestamp: Date.now(), - }) - submitApprovalViaSocket(sessionId, approval.choice, approval.all) - return true - } - function primeCompletionBellIfEnabled() { if (useSettingsStore().display.bell_on_complete) { primeCompletionSound() @@ -728,13 +641,7 @@ export const useChatStore = defineStore('chat', () => { } async function sendMessage(content: string, attachments?: Attachment[]) { - const trimmed = content.trim() - if (isStreaming.value) { - const sid = activeSessionId.value - if (sid && !attachments?.length && submitApprovalCommand(sid, trimmed)) return - } - - if ((!trimmed && !(attachments && attachments.length > 0))) return + if ((!content.trim() && !(attachments && attachments.length > 0))) return primeCompletionBellIfEnabled() @@ -1043,17 +950,6 @@ export const useChatStore = defineStore('chat', () => { break } - case 'approval.request': - case 'approval.requested': { - addApprovalRequestMessage(sid, { ...evt, event: 'approval.request' }) - break - } - - case 'approval.responded': { - addApprovalResponseMessage(sid, evt) - break - } - case 'run.completed': { const msgs = getSessionMsgs(sid) const lastMsg = activeAssistantMessageId @@ -1465,17 +1361,6 @@ export const useChatStore = defineStore('chat', () => { break } - case 'approval.request': - case 'approval.requested': { - addApprovalRequestMessage(sid, { ...evt, event: 'approval.request' }) - break - } - - case 'approval.responded': { - addApprovalResponseMessage(sid, evt) - break - } - case 'run.completed': { const hasQueue = (evt as any).queue_remaining > 0 if (hasQueue) { @@ -1624,8 +1509,6 @@ export const useChatStore = defineStore('chat', () => { onCompressionCompleted: (evt) => handleEvent(evt), onAbortStarted: (evt) => handleEvent(evt), onAbortCompleted: (evt) => handleEvent(evt), - onApprovalRequest: (evt) => handleEvent(evt), - onApprovalResponded: (evt) => handleEvent(evt), onUsageUpdated: (evt) => handleEvent(evt), onRunQueued: (evt) => handleEvent(evt), }) diff --git a/packages/client/src/utils/approval-commands.ts b/packages/client/src/utils/approval-commands.ts deleted file mode 100644 index 4a474b2..0000000 --- a/packages/client/src/utils/approval-commands.ts +++ /dev/null @@ -1,29 +0,0 @@ -export type ApprovalChoice = 'once' | 'session' | 'always' | 'deny' - -export interface ApprovalCommand { - choice: ApprovalChoice - all: boolean -} - -const APPROVAL_COMMAND_RE = /^\/(approve|deny)(?:\s+(session|always|all))?\s*$/i - -export function parseApprovalCommand(input: string): ApprovalCommand | null { - const match = input.trim().match(APPROVAL_COMMAND_RE) - if (!match) return null - - const verb = match[1].toLowerCase() - const modifier = match[2]?.toLowerCase() - - if (verb === 'deny') { - if (modifier && modifier !== 'all') return null - return { choice: 'deny', all: modifier === 'all' } - } - - if (modifier === 'session') return { choice: 'session', all: false } - if (modifier === 'always') return { choice: 'always', all: false } - return { choice: 'once', all: modifier === 'all' } -} - -export function isApprovalCommand(input: string): boolean { - return parseApprovalCommand(input) != null -} diff --git a/packages/server/src/services/hermes/chat-run-socket.ts b/packages/server/src/services/hermes/chat-run-socket.ts index 5c41fa0..505598e 100644 --- a/packages/server/src/services/hermes/chat-run-socket.ts +++ b/packages/server/src/services/hermes/chat-run-socket.ts @@ -149,8 +149,6 @@ interface SessionMessage { codex_reasoning_items?: string | null } -type ApprovalChoice = 'once' | 'session' | 'always' | 'deny' - interface QueuedRun { queue_id: string input: string | ContentBlock[] @@ -270,13 +268,6 @@ export class ChatRunSocket { void this.handleAbort(socket, data.session_id) } }) - - socket.on('approval.respond', (data: { session_id?: string; choice?: ApprovalChoice; all?: boolean }) => { - const choice = data.choice - if (data.session_id && (choice === 'once' || choice === 'session' || choice === 'always' || choice === 'deny')) { - void this.handleApprovalRespond(socket, data.session_id, choice, data.all === true) - } - }) } private handleMessage(messages: SessionMessage[], sid: string): any[] { let _messages = [] @@ -890,7 +881,6 @@ export class ChatRunSocket { const headers: Record = { 'Content-Type': 'application/json' } if (apiKey) headers['Authorization'] = `Bearer ${apiKey}` - if (session_id) headers['X-Hermes-Session-Key'] = this.getGatewaySessionKey(session_id) // Convert input from ContentBlock[] to Anthropic format (with base64 images) if (isContentBlockArray(input)) { body.input = await convertContentBlocks(input) @@ -950,13 +940,12 @@ export class ChatRunSocket { const eventsUrl = new URL(`${upstream}/v1/runs/${runId}/events`) // Use Authorization header instead of query parameter for better compatibility - const eventSourceInit: any = (apiKey || session_id) ? { + const eventSourceInit: any = apiKey ? { fetch: (url: string, init: any = {}) => fetch(url, { ...init, headers: { ...(init.headers || {}), - ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), - ...(session_id ? { 'X-Hermes-Session-Key': this.getGatewaySessionKey(session_id) } : {}), + Authorization: `Bearer ${apiKey}`, }, }), } : {} @@ -978,19 +967,6 @@ export class ChatRunSocket { logger.info('[chat-run-socket] upstream event: %s', parsed.event) } - // Surface structured approval protocol events immediately instead of - // letting the client sit in a silent working state. New API-server - // builds emit approval.request; older payloads with approval_required - // are normalized only for compatibility while the upstream rolls out. - const approvalPayload = this.normalizeApprovalRequest(parsed, runId) - if (approvalPayload) { - if (session_id) this.replaceState(session_id, 'approval.request', approvalPayload) - emit('approval.request', approvalPayload) - if (parsed.event === 'approval_required' || parsed.event === 'approval.requested' || parsed.event === 'approval.request') { - return - } - } - // Track messages into sessionMap if (session_id) { const state = this.sessionMap.get(session_id) @@ -1274,13 +1250,8 @@ export class ChatRunSocket { const apiKey = this.gatewayManager.getApiKey(profile) || undefined try { - const headers: Record = { - 'Content-Type': 'application/json', - 'X-Hermes-Session-Key': this.getGatewaySessionKey(sessionId), - } - if (apiKey) { - headers['Authorization'] = `Bearer ${apiKey}` - } + const headers: Record = { 'Content-Type': 'application/json' } + if (apiKey) headers['Authorization'] = `Bearer ${apiKey}` logger.info({ sessionId, runId, upstream }, '[chat-run-socket][abort] calling upstream stop') await fetch(`${upstream}/v1/runs/${runId}/stop`, { @@ -1311,77 +1282,6 @@ export class ChatRunSocket { await this.markAbortCompleted(socket, sessionId, runId) } - private async handleApprovalRespond(socket: Socket, sessionId: string, choice: ApprovalChoice, all: boolean) { - const state = this.sessionMap.get(sessionId) - const runId = state?.runId - if (!state?.isWorking || !runId) { - this.emitToSession(socket, sessionId, 'approval.responded', { - event: 'approval.responded', - choice, - all, - resolved: 0, - error: 'No active run for this session', - }) - return - } - - const profile = state.profile || 'default' - const upstream = this.gatewayManager.getUpstream(profile).replace(/\/$/, '') - const apiKey = this.gatewayManager.getApiKey(profile) || undefined - const sessionKey = this.getGatewaySessionKey(sessionId) - const headers: Record = { - 'Content-Type': 'application/json', - 'X-Hermes-Session-Key': sessionKey, - } - if (apiKey) headers['Authorization'] = `Bearer ${apiKey}` - - const unsupported = await this.getApprovalCapabilityError(upstream, headers) - if (unsupported) { - const event = { - event: 'approval.responded', - run_id: runId, - choice, - all, - resolved: 0, - error: unsupported, - } - this.replaceState(sessionId, 'approval.responded', event) - this.emitToSession(socket, sessionId, 'approval.responded', event) - return - } - - let resolved = 0 - let error = '' - try { - const res = await fetch(`${upstream}/v1/runs/${runId}/approval`, { - method: 'POST', - headers, - body: JSON.stringify({ choice, all }), - signal: AbortSignal.timeout(30_000), - }) - if (!res.ok) { - const text = await res.text().catch(() => '') - error = `Upstream ${res.status}: ${text}` - } else { - const json = await res.json().catch(() => ({})) as any - resolved = Number(json.resolved ?? 0) || 0 - } - } catch (err: any) { - error = err?.message || 'Approval resolve failed' - } - - const event = { - event: 'approval.responded', - run_id: runId, - choice, - all, - resolved, - ...(error ? { error } : {}), - } - this.replaceState(sessionId, 'approval.responded', event) - this.emitToSession(socket, sessionId, 'approval.responded', event) - } - /** 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) @@ -1737,59 +1637,8 @@ export class ChatRunSocket { logger.info('[chat-run-socket] enqueued ephemeral session %s for deletion', hermesSessionId) } catch { /* best-effort */ } } - private async getApprovalCapabilityError(upstream: string, headers: Record): Promise { - try { - const res = await fetch(`${upstream}/v1/capabilities`, { - method: 'GET', - headers, - signal: AbortSignal.timeout(10_000), - }) - if (!res.ok) { - return `Hermes Agent API does not advertise approval support; upgrade to Hermes Agent main or a build containing NousResearch/hermes-agent#21899. (/v1/capabilities returned ${res.status})` - } - const caps = await res.json().catch(() => ({})) as any - const features = caps?.features || {} - const endpoints = caps?.endpoints || {} - if (features.approval_events === true && features.run_approval_response === true && endpoints.run_approval?.path) { - return null - } - return 'Hermes Agent API does not support run approval control plane; upgrade to Hermes Agent main or a build containing NousResearch/hermes-agent#21899.' - } catch (err: any) { - return `Unable to verify Hermes Agent approval capabilities: ${err?.message || 'capability request failed'}` - } - } - /** Get stable gateway approval/memory key for a Web UI chat session. */ - private getGatewaySessionKey(sessionId: string): string { - return `webui:${sessionId}`.replace(/[\r\n\x00]/g, '_') - } - - private normalizeApprovalRequest(parsed: any, runId?: string): any | null { - const eventName = parsed?.event - const status = parsed?.status || parsed?.data?.status || parsed?.output?.status - const isApprovalEvent = eventName === 'approval_required' || eventName === 'approval.requested' || eventName === 'approval.request' - if (!isApprovalEvent && status !== 'approval_required') return null - - const source = parsed?.data && typeof parsed.data === 'object' - ? parsed.data - : parsed?.output && typeof parsed.output === 'object' - ? parsed.output - : parsed - - return { - event: 'approval.request', - run_id: parsed?.run_id || runId, - timestamp: parsed?.timestamp || Date.now() / 1000, - command: source?.command, - description: source?.description, - pattern_key: source?.pattern_key, - pattern_keys: source?.pattern_keys, - choices: Array.isArray(source?.choices) ? source.choices : ['once', 'session', 'always', 'deny'], - message: source?.message, - } - } - /** Get or create session state in sessionMap */ private getOrCreateSession(sessionId: string): SessionState { let state = this.sessionMap.get(sessionId) diff --git a/tests/client/approval-commands.test.ts b/tests/client/approval-commands.test.ts deleted file mode 100644 index e1f93b1..0000000 --- a/tests/client/approval-commands.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { isApprovalCommand, parseApprovalCommand } from '../../packages/client/src/utils/approval-commands' - -describe('approval command parsing', () => { - it('maps slash commands to the upstream run approval choices', () => { - expect(parseApprovalCommand('/approve')).toEqual({ choice: 'once', all: false }) - expect(parseApprovalCommand('/approve session')).toEqual({ choice: 'session', all: false }) - expect(parseApprovalCommand('/approve always')).toEqual({ choice: 'always', all: false }) - expect(parseApprovalCommand('/deny')).toEqual({ choice: 'deny', all: false }) - }) - - it('keeps all as resolve-all, not as always allow', () => { - expect(parseApprovalCommand(' /approve all ')).toEqual({ choice: 'once', all: true }) - expect(parseApprovalCommand('/DENY ALL')).toEqual({ choice: 'deny', all: true }) - }) - - it('rejects ordinary chat text and malformed approval-like commands', () => { - expect(parseApprovalCommand('approve')).toBeNull() - expect(parseApprovalCommand('/approve please')).toBeNull() - expect(parseApprovalCommand('/deny session')).toBeNull() - expect(parseApprovalCommand('/always')).toBeNull() - expect(isApprovalCommand('hello')).toBe(false) - }) -}) diff --git a/tests/client/chat-input-approval.test.ts b/tests/client/chat-input-approval.test.ts deleted file mode 100644 index b82d691..0000000 --- a/tests/client/chat-input-approval.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -// @vitest-environment jsdom -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { mount } from '@vue/test-utils' - -const mockChatStore = vi.hoisted(() => ({ - isStreaming: true, - isAborting: false, - activeSession: null as any, - setAutoPlaySpeech: vi.fn(), - sendMessage: vi.fn(), - stopStreaming: vi.fn(), -})) - -vi.mock('../../packages/client/src/stores/hermes/chat', () => ({ - useChatStore: () => mockChatStore, -})) - -vi.mock('../../packages/client/src/stores/hermes/app', () => ({ - useAppStore: () => ({ selectedModel: 'test-model' }), -})) - -vi.mock('../../packages/client/src/stores/hermes/profiles', () => ({ - useProfilesStore: () => ({ activeProfileName: 'default' }), -})) - -vi.mock('../../packages/client/src/api/hermes/sessions', () => ({ - fetchContextLength: vi.fn(() => Promise.resolve(200000)), -})) - -vi.mock('vue-i18n', () => ({ - useI18n: () => ({ t: (key: string) => key }), -})) - -vi.mock('naive-ui', async (importActual) => { - const actual = await importActual() - return { - ...actual, - useMessage: () => ({ - error: vi.fn(), - success: vi.fn(), - }), - } -}) - -import ChatInput from '../../packages/client/src/components/hermes/chat/ChatInput.vue' - -function mountInput() { - return mount(ChatInput, { - global: { - stubs: { - NButton: { - props: ['disabled'], - emits: ['click'], - template: '', - }, - NTooltip: { - template: '
', - }, - NSwitch: { - props: ['value'], - emits: ['update:value'], - template: '', - }, - }, - }, - }) -} - -describe('ChatInput approval commands while streaming', () => { - beforeEach(() => { - vi.clearAllMocks() - mockChatStore.isStreaming = true - mockChatStore.isAborting = false - }) - - it('allows /approve to be submitted while a run is streaming', async () => { - const wrapper = mountInput() - const textarea = wrapper.find('textarea') - await textarea.setValue('/approve') - - const buttons = wrapper.findAll('button') - const sendButton = buttons[buttons.length - 1] - expect(sendButton.attributes('disabled')).toBeUndefined() - - await sendButton.trigger('click') - expect(mockChatStore.sendMessage).toHaveBeenCalledWith('/approve', undefined) - expect((textarea.element as HTMLTextAreaElement).value).toBe('') - }) - - it('allows ordinary messages while streaming so the run queue can handle them', async () => { - const wrapper = mountInput() - const textarea = wrapper.find('textarea') - await textarea.setValue('hello while busy') - - const buttons = wrapper.findAll('button') - const sendButton = buttons[buttons.length - 1] - expect(sendButton.attributes('disabled')).toBeUndefined() - - await textarea.trigger('keydown', { key: 'Enter' }) - expect(mockChatStore.sendMessage).toHaveBeenCalledWith('hello while busy', undefined) - expect((textarea.element as HTMLTextAreaElement).value).toBe('') - }) - - it('allows session/always/all approval commands while streaming', async () => { - const wrapper = mountInput() - const textarea = wrapper.find('textarea') - - await textarea.setValue('/approve session') - await textarea.trigger('keydown', { key: 'Enter' }) - expect(mockChatStore.sendMessage).toHaveBeenLastCalledWith('/approve session', undefined) - - await textarea.setValue('/approve always') - await textarea.trigger('keydown', { key: 'Enter' }) - expect(mockChatStore.sendMessage).toHaveBeenLastCalledWith('/approve always', undefined) - - await textarea.setValue('/deny all') - await textarea.trigger('keydown', { key: 'Enter' }) - expect(mockChatStore.sendMessage).toHaveBeenLastCalledWith('/deny all', undefined) - }) -}) diff --git a/tests/client/chat-store-approval.test.ts b/tests/client/chat-store-approval.test.ts deleted file mode 100644 index cebbeca..0000000 --- a/tests/client/chat-store-approval.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -// @vitest-environment jsdom -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createPinia, setActivePinia } from 'pinia' - -const chatApiMocks = vi.hoisted(() => ({ - lastRunEventHandler: null as null | ((evt: any) => void), - startRunViaSocket: vi.fn((_payload: any, onEvent: (evt: any) => void) => { - chatApiMocks.lastRunEventHandler = onEvent - return { abort: vi.fn() } - }), - resumeSession: vi.fn((sessionId: string, onResumed: (data: any) => void) => { - onResumed({ session_id: sessionId, messages: [], isWorking: false, events: [] }) - return {} as any - }), - registerSessionHandlers: vi.fn(() => vi.fn()), - unregisterSessionHandlers: vi.fn(), - getChatRunSocket: vi.fn(() => ({ emit: vi.fn() })), - submitApprovalViaSocket: vi.fn(), -})) - -vi.mock('@/api/hermes/chat', () => chatApiMocks) - -vi.mock('@/api/hermes/sessions', () => ({ - deleteSession: vi.fn(), - fetchSession: vi.fn(), - fetchSessions: vi.fn(() => Promise.resolve({ sessions: [] })), -})) - -vi.mock('@/api/client', () => ({ - getApiKey: vi.fn(() => ''), -})) - -import { useChatStore } from '@/stores/hermes/chat' - -describe('chat store approval commands', () => { - beforeEach(() => { - setActivePinia(createPinia()) - vi.clearAllMocks() - }) - - it('submits /approve through the active streaming run instead of starting a new run', async () => { - const store = useChatStore() - - store.newChat() - const sessionId = store.activeSessionId! - await store.sendMessage('start risky work') - expect(chatApiMocks.startRunViaSocket).toHaveBeenCalledTimes(1) - expect(store.isStreaming).toBe(true) - - await store.sendMessage('/approve session') - - expect(chatApiMocks.startRunViaSocket).toHaveBeenCalledTimes(1) - expect(chatApiMocks.submitApprovalViaSocket).toHaveBeenCalledWith(sessionId, 'session', false) - expect(store.messages.at(-1)?.role).toBe('user') - expect(store.messages.at(-1)?.content).toBe('/approve session') - }) - - it('queues ordinary chat text while a run is streaming', async () => { - const store = useChatStore() - - store.newChat() - await store.sendMessage('start risky work') - expect(store.isStreaming).toBe(true) - - await store.sendMessage('this should queue behind the active run') - - expect(chatApiMocks.startRunViaSocket).toHaveBeenCalledTimes(2) - expect(chatApiMocks.startRunViaSocket.mock.calls[1][0]).toMatchObject({ - input: 'this should queue behind the active run', - session_id: store.activeSessionId, - }) - expect(chatApiMocks.startRunViaSocket.mock.calls[1][0].queue_id).toEqual(expect.any(String)) - expect(chatApiMocks.submitApprovalViaSocket).not.toHaveBeenCalled() - expect(store.messages.map(m => m.content)).not.toContain('this should queue behind the active run') - }) - - it('deduplicates repeated approval request pattern labels', async () => { - const store = useChatStore() - - store.newChat() - await store.sendMessage('start risky work') - const onEvent = chatApiMocks.lastRunEventHandler - expect(onEvent).toEqual(expect.any(Function)) - - onEvent?.({ - event: 'approval.request', - run_id: 'run-approval-1', - command: "bash -lc 'printf ok'", - description: 'shell command via -c/-lc flag', - pattern_key: 'shell command via -c/-lc flag', - pattern_keys: ['shell command via -c/-lc flag'], - choices: ['once', 'session', 'always', 'deny'], - }) - - const approvalPrompt = store.messages.find(m => - m.role === 'system' && m.content.includes('Approval required'), - ) - expect(approvalPrompt?.content).toContain('Patterns: shell command via -c/-lc flag') - expect(approvalPrompt?.content).not.toContain('shell command via -c/-lc flag, shell command via -c/-lc flag') - }) - - it('deduplicates the optimistic and upstream approval response for the same run', async () => { - const store = useChatStore() - - store.newChat() - await store.sendMessage('start risky work') - const onEvent = chatApiMocks.lastRunEventHandler - expect(onEvent).toEqual(expect.any(Function)) - - onEvent?.({ - event: 'approval.responded', - run_id: 'run-approval-1', - choice: 'once', - all: false, - resolved: 1, - }) - onEvent?.({ - event: 'approval.responded', - run_id: 'run-approval-1', - timestamp: Date.now() / 1000, - choice: 'once', - resolved: 1, - }) - - const approvalResponses = store.messages.filter(m => - m.role === 'system' && m.content.includes('Approval approved once. Resolved 1 pending request.'), - ) - expect(approvalResponses).toHaveLength(1) - }) -}) diff --git a/tests/client/message-item-highlight.test.ts b/tests/client/message-item-highlight.test.ts index aea7ef4..3f22337 100644 --- a/tests/client/message-item-highlight.test.ts +++ b/tests/client/message-item-highlight.test.ts @@ -48,23 +48,6 @@ describe('MessageItem tool details', () => { }) }) - it('renders system approval prompt content in the chat bubble', () => { - const wrapper = mount(MessageItem, { - props: { - message: { - id: 'approval-request', - role: 'system', - content: 'Approval required\n\nCommand:\n```\nbash -lc \'printf ok\'\n```\n\nReply with /approve to approve once.', - timestamp: Date.now(), - } satisfies Message, - }, - }) - - expect(wrapper.text()).toContain('Approval required') - expect(wrapper.text()).toContain('Reply with /approve') - expect(wrapper.find('.message-bubble.system').exists()).toBe(true) - }) - it('renders highlighted code blocks for tool arguments and tool results', async () => { const wrapper = mount(MessageItem, { props: { diff --git a/tests/server/chat-run-socket-approval.test.ts b/tests/server/chat-run-socket-approval.test.ts deleted file mode 100644 index a0a0d5b..0000000 --- a/tests/server/chat-run-socket-approval.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ChatRunSocket } from '../../packages/server/src/services/hermes/chat-run-socket' - -function makeChatRunSocket() { - const emit = vi.fn() - const nsp = { - use: vi.fn(), - on: vi.fn(), - to: vi.fn(() => ({ emit })), - emit, - adapter: { rooms: new Map() }, - } - const io = { of: vi.fn(() => nsp) } - const gatewayManager = { - getUpstream: vi.fn(() => 'http://127.0.0.1:9999'), - getApiKey: vi.fn(() => 'test-key'), - } - return new ChatRunSocket(io as any, gatewayManager) -} - -describe('ChatRunSocket approval event normalization', () => { - beforeEach(() => { - vi.unstubAllGlobals() - }) - - it('normalizes upstream approval_required status into canonical approval.request for clients', () => { - const service = makeChatRunSocket() as any - - const event = service.normalizeApprovalRequest({ - event: 'tool.completed', - status: 'approval_required', - command: 'rm -rf /tmp/example', - description: 'dangerous command', - pattern_key: 'rm_rf', - }, 'run-123') - - expect(event).toMatchObject({ - event: 'approval.request', - run_id: 'run-123', - command: 'rm -rf /tmp/example', - description: 'dangerous command', - pattern_key: 'rm_rf', - choices: ['once', 'session', 'always', 'deny'], - }) - }) - - it('passes through upstream approval.request choices and pattern_keys', () => { - const service = makeChatRunSocket() as any - - const event = service.normalizeApprovalRequest({ - event: 'approval.request', - run_id: 'run-456', - command: 'git reset --hard HEAD', - pattern_keys: ['git_reset_hard'], - choices: ['once', 'session', 'always', 'deny'], - }, 'ignored') - - expect(event).toMatchObject({ - event: 'approval.request', - run_id: 'run-456', - pattern_keys: ['git_reset_hard'], - choices: ['once', 'session', 'always', 'deny'], - }) - }) - - it('posts approval responses to the upstream run-scoped approval endpoint after capability check', async () => { - const service = makeChatRunSocket() as any - service.sessionMap.set('session-1', { - messages: [], - isWorking: true, - events: [], - runId: 'run-123', - profile: 'default', - }) - const fetchMock = vi.fn() - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - features: { approval_events: true, run_approval_response: true }, - endpoints: { run_approval: { method: 'POST', path: '/v1/runs/{run_id}/approval' } }, - }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ resolved: 1, choice: 'session' }), - }) - vi.stubGlobal('fetch', fetchMock) - - await service.handleApprovalRespond({ connected: true, emit: vi.fn() }, 'session-1', 'session', false) - - expect(fetchMock).toHaveBeenCalledTimes(2) - expect(fetchMock.mock.calls[0][0]).toBe('http://127.0.0.1:9999/v1/capabilities') - expect(fetchMock.mock.calls[1][0]).toBe('http://127.0.0.1:9999/v1/runs/run-123/approval') - expect(JSON.parse(fetchMock.mock.calls[1][1].body)).toEqual({ choice: 'session', all: false }) - }) - - it('ignores ordinary upstream events', () => { - const service = makeChatRunSocket() as any - - expect(service.normalizeApprovalRequest({ event: 'tool.completed', status: 'done' }, 'run-123')).toBeNull() - }) -}) diff --git a/tests/server/session-sync.test.ts b/tests/server/session-sync.test.ts index 8ac25df..ade49fa 100644 --- a/tests/server/session-sync.test.ts +++ b/tests/server/session-sync.test.ts @@ -2,14 +2,12 @@ * Tests for session-sync service */ import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { getDb } from '../../packages/server/src/db/index' -import { initAllStores } from '../../packages/server/src/db/hermes/init' +import { getDb, ensureTable } from '../../packages/server/src/db/index' import { syncAllHermesSessionsOnStartup } from '../../packages/server/src/services/hermes/session-sync' describe('session-sync', () => { beforeEach(() => { // Reset database before each test - initAllStores() const db = getDb() if (db) { db.exec('DELETE FROM sessions')