From 56c7b59eaf8acd1b6dd8159f3a25fbc620d44af5 Mon Sep 17 00:00:00 2001 From: Zhicheng Han <43314240+hanzckernel@users.noreply.github.com> Date: Fri, 8 May 2026 16:59:36 +0200 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AE=A1=E6=89=B9=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E5=9C=A8=E8=81=8A=E5=A4=A9=E4=B8=AD=E6=97=A0=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E4=B8=94=E6=97=A0=E6=B3=95=E5=93=8D=E5=BA=94=20(#467)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: support run approval prompts in chat * fix(chat): render approval prompts * fix(chat): dedupe approval pattern labels * chore: sync approval flow with current main - update Hermes Agent approval support guidance to PR #21899 - initialize Hermes table schemas in session-sync tests --- packages/client/src/api/hermes/chat.ts | 62 +++++++ .../src/components/hermes/chat/ChatInput.vue | 2 +- .../components/hermes/chat/MessageItem.vue | 6 +- packages/client/src/stores/hermes/chat.ts | 121 ++++++++++++- .../client/src/utils/approval-commands.ts | 29 ++++ .../src/services/hermes/chat-run-socket.ts | 159 +++++++++++++++++- tests/client/approval-commands.test.ts | 24 +++ tests/client/chat-input-approval.test.ts | 120 +++++++++++++ tests/client/chat-store-approval.test.ts | 130 ++++++++++++++ tests/client/message-item-highlight.test.ts | 17 ++ tests/server/chat-run-socket-approval.test.ts | 102 +++++++++++ tests/server/session-sync.test.ts | 4 +- 12 files changed, 767 insertions(+), 9 deletions(-) create mode 100644 packages/client/src/utils/approval-commands.ts create mode 100644 tests/client/approval-commands.test.ts create mode 100644 tests/client/chat-input-approval.test.ts create mode 100644 tests/client/chat-store-approval.test.ts create mode 100644 tests/server/chat-run-socket-approval.test.ts diff --git a/packages/client/src/api/hermes/chat.ts b/packages/client/src/api/hermes/chat.ts index a9ef556..3ebab2e 100644 --- a/packages/client/src/api/hermes/chat.ts +++ b/packages/client/src/api/hermes/chat.ts @@ -1,4 +1,5 @@ import { io, type Socket } from 'socket.io-client' +import type { ApprovalChoice } from '../../utils/approval-commands' import { request, getBaseUrlValue, getApiKey } from '../client' export type ContentBlock = @@ -39,6 +40,14 @@ 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 @@ -75,6 +84,8 @@ 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 }>() @@ -275,6 +286,32 @@ 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 */ @@ -310,6 +347,8 @@ 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 } @@ -392,6 +431,11 @@ 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) @@ -519,6 +563,14 @@ 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) @@ -544,6 +596,16 @@ 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 3afb724..543fe1e 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 (!text && attachments.value.length === 0) return + if (!canSend.value) 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 b863801..cc7cc04 100644 --- a/packages/client/src/components/hermes/chat/MessageItem.vue +++ b/packages/client/src/components/hermes/chat/MessageItem.vue @@ -595,11 +595,15 @@ onBeforeUnmount(() => { - + + diff --git a/packages/client/src/stores/hermes/chat.ts b/packages/client/src/stores/hermes/chat.ts index f064435..39d63fd 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, type RunEvent, type ContentBlock as ContentBlockImport } from '@/api/hermes/chat' +import { startRunViaSocket, resumeSession, registerSessionHandlers, unregisterSessionHandlers, getChatRunSocket, submitApprovalViaSocket, 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,6 +8,7 @@ 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 @@ -496,6 +497,10 @@ 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') { @@ -628,6 +633,88 @@ 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() @@ -641,7 +728,13 @@ export const useChatStore = defineStore('chat', () => { } async function sendMessage(content: string, attachments?: Attachment[]) { - if ((!content.trim() && !(attachments && attachments.length > 0))) return + 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 primeCompletionBellIfEnabled() @@ -950,6 +1043,17 @@ 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 @@ -1361,6 +1465,17 @@ 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) { @@ -1509,6 +1624,8 @@ 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 new file mode 100644 index 0000000..4a474b2 --- /dev/null +++ b/packages/client/src/utils/approval-commands.ts @@ -0,0 +1,29 @@ +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 505598e..5c41fa0 100644 --- a/packages/server/src/services/hermes/chat-run-socket.ts +++ b/packages/server/src/services/hermes/chat-run-socket.ts @@ -149,6 +149,8 @@ interface SessionMessage { codex_reasoning_items?: string | null } +type ApprovalChoice = 'once' | 'session' | 'always' | 'deny' + interface QueuedRun { queue_id: string input: string | ContentBlock[] @@ -268,6 +270,13 @@ 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 = [] @@ -881,6 +890,7 @@ 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) @@ -940,12 +950,13 @@ 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 ? { + const eventSourceInit: any = (apiKey || session_id) ? { fetch: (url: string, init: any = {}) => fetch(url, { ...init, headers: { ...(init.headers || {}), - Authorization: `Bearer ${apiKey}`, + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + ...(session_id ? { 'X-Hermes-Session-Key': this.getGatewaySessionKey(session_id) } : {}), }, }), } : {} @@ -967,6 +978,19 @@ 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) @@ -1250,8 +1274,13 @@ export class ChatRunSocket { const apiKey = this.gatewayManager.getApiKey(profile) || undefined try { - const headers: Record = { 'Content-Type': 'application/json' } - if (apiKey) headers['Authorization'] = `Bearer ${apiKey}` + const headers: Record = { + 'Content-Type': 'application/json', + 'X-Hermes-Session-Key': this.getGatewaySessionKey(sessionId), + } + 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`, { @@ -1282,6 +1311,77 @@ 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) @@ -1637,8 +1737,59 @@ 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 new file mode 100644 index 0000000..e1f93b1 --- /dev/null +++ b/tests/client/approval-commands.test.ts @@ -0,0 +1,24 @@ +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 new file mode 100644 index 0000000..b82d691 --- /dev/null +++ b/tests/client/chat-input-approval.test.ts @@ -0,0 +1,120 @@ +// @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 new file mode 100644 index 0000000..cebbeca --- /dev/null +++ b/tests/client/chat-store-approval.test.ts @@ -0,0 +1,130 @@ +// @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 3f22337..aea7ef4 100644 --- a/tests/client/message-item-highlight.test.ts +++ b/tests/client/message-item-highlight.test.ts @@ -48,6 +48,23 @@ 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 new file mode 100644 index 0000000..a0a0d5b --- /dev/null +++ b/tests/server/chat-run-socket-approval.test.ts @@ -0,0 +1,102 @@ +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 ade49fa..8ac25df 100644 --- a/tests/server/session-sync.test.ts +++ b/tests/server/session-sync.test.ts @@ -2,12 +2,14 @@ * Tests for session-sync service */ import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { getDb, ensureTable } from '../../packages/server/src/db/index' +import { getDb } from '../../packages/server/src/db/index' +import { initAllStores } from '../../packages/server/src/db/hermes/init' 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')