修复审批请求在聊天中无提示且无法响应 (#467)

* 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
This commit is contained in:
Zhicheng Han
2026-05-08 16:59:36 +02:00
committed by GitHub
parent 51fde26797
commit 56c7b59eaf
12 changed files with 767 additions and 9 deletions
+62
View File
@@ -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<string, {
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
}>()
@@ -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')
}
@@ -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 = ''
@@ -595,11 +595,15 @@ onBeforeUnmount(() => {
<MarkdownRenderer v-else-if="message.content" :content="message.content" />
</template>
<!-- Render assistant message content -->
<!-- Render assistant/system message content -->
<MarkdownRenderer
v-if="message.role === 'assistant' && message.content && !parsedThinking.body"
:content="message.content"
/>
<MarkdownRenderer
v-else-if="message.role === 'system' && message.content"
:content="message.content"
/>
<span v-if="message.isStreaming && !message.content" class="streaming-dots">
<span></span><span></span><span></span>
+119 -2
View File
@@ -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),
})
@@ -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
}