This reverts commit 56c7b59eaf.
This commit is contained in:
@@ -1,5 +1,4 @@
|
|||||||
import { io, type Socket } from 'socket.io-client'
|
import { io, type Socket } from 'socket.io-client'
|
||||||
import type { ApprovalChoice } from '../../utils/approval-commands'
|
|
||||||
import { request, getBaseUrlValue, getApiKey } from '../client'
|
import { request, getBaseUrlValue, getApiKey } from '../client'
|
||||||
|
|
||||||
export type ContentBlock =
|
export type ContentBlock =
|
||||||
@@ -40,14 +39,6 @@ export interface RunEvent {
|
|||||||
/** Final response text on `run.completed`. May be empty/null if the agent
|
/** Final response text on `run.completed`. May be empty/null if the agent
|
||||||
* silently swallowed an upstream error — see chat store for fallback. */
|
* silently swallowed an upstream error — see chat store for fallback. */
|
||||||
output?: string | null
|
output?: string | null
|
||||||
command?: string
|
|
||||||
description?: string
|
|
||||||
pattern_key?: string
|
|
||||||
pattern_keys?: string[]
|
|
||||||
choices?: ApprovalChoice[]
|
|
||||||
resolved?: number
|
|
||||||
choice?: ApprovalChoice
|
|
||||||
all?: boolean
|
|
||||||
usage?: {
|
usage?: {
|
||||||
input_tokens: number
|
input_tokens: number
|
||||||
output_tokens: number
|
output_tokens: number
|
||||||
@@ -84,8 +75,6 @@ const sessionEventHandlers = new Map<string, {
|
|||||||
onCompressionCompleted: (event: RunEvent) => void
|
onCompressionCompleted: (event: RunEvent) => void
|
||||||
onAbortStarted: (event: RunEvent) => void
|
onAbortStarted: (event: RunEvent) => void
|
||||||
onAbortCompleted: (event: RunEvent) => void
|
onAbortCompleted: (event: RunEvent) => void
|
||||||
onApprovalRequest: (event: RunEvent) => void
|
|
||||||
onApprovalResponded: (event: RunEvent) => void
|
|
||||||
onUsageUpdated: (event: RunEvent) => void
|
onUsageUpdated: (event: RunEvent) => void
|
||||||
onRunQueued?: (event: RunEvent) => void
|
onRunQueued?: (event: RunEvent) => void
|
||||||
}>()
|
}>()
|
||||||
@@ -286,32 +275,6 @@ function globalAbortCompletedHandler(event: RunEvent): void {
|
|||||||
sessionEventHandlers.delete(sid)
|
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
|
* Global usage.updated event handler
|
||||||
*/
|
*/
|
||||||
@@ -347,8 +310,6 @@ export function registerSessionHandlers(
|
|||||||
onCompressionCompleted: (event: RunEvent) => void
|
onCompressionCompleted: (event: RunEvent) => void
|
||||||
onAbortStarted: (event: RunEvent) => void
|
onAbortStarted: (event: RunEvent) => void
|
||||||
onAbortCompleted: (event: RunEvent) => void
|
onAbortCompleted: (event: RunEvent) => void
|
||||||
onApprovalRequest: (event: RunEvent) => void
|
|
||||||
onApprovalResponded: (event: RunEvent) => void
|
|
||||||
onUsageUpdated: (event: RunEvent) => void
|
onUsageUpdated: (event: RunEvent) => void
|
||||||
onRunQueued?: (event: RunEvent) => void
|
onRunQueued?: (event: RunEvent) => void
|
||||||
}
|
}
|
||||||
@@ -431,11 +392,6 @@ export function connectChatRun(): Socket {
|
|||||||
chatRunSocket.on('abort.started', globalAbortStartedHandler)
|
chatRunSocket.on('abort.started', globalAbortStartedHandler)
|
||||||
chatRunSocket.on('abort.completed', globalAbortCompletedHandler)
|
chatRunSocket.on('abort.completed', globalAbortCompletedHandler)
|
||||||
|
|
||||||
// Approval events
|
|
||||||
chatRunSocket.on('approval.requested', globalApprovalRequestHandler)
|
|
||||||
chatRunSocket.on('approval.request', globalApprovalRequestHandler)
|
|
||||||
chatRunSocket.on('approval.responded', globalApprovalRespondedHandler)
|
|
||||||
|
|
||||||
// Usage events
|
// Usage events
|
||||||
chatRunSocket.on('usage.updated', globalUsageUpdatedHandler)
|
chatRunSocket.on('usage.updated', globalUsageUpdatedHandler)
|
||||||
|
|
||||||
@@ -563,14 +519,6 @@ export function startRunViaSocket(
|
|||||||
closed = true
|
closed = true
|
||||||
onDone()
|
onDone()
|
||||||
},
|
},
|
||||||
onApprovalRequest: (evt: RunEvent) => {
|
|
||||||
if (closed) return
|
|
||||||
onEvent(evt)
|
|
||||||
},
|
|
||||||
onApprovalResponded: (evt: RunEvent) => {
|
|
||||||
if (closed) return
|
|
||||||
onEvent(evt)
|
|
||||||
},
|
|
||||||
onUsageUpdated: (evt: RunEvent) => {
|
onUsageUpdated: (evt: RunEvent) => {
|
||||||
if (closed) return
|
if (closed) return
|
||||||
onEvent(evt)
|
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 }> }> {
|
export async function fetchModels(): Promise<{ data: Array<{ id: string }> }> {
|
||||||
return request('/api/hermes/v1/models')
|
return request('/api/hermes/v1/models')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ function handleDrop(e: DragEvent) {
|
|||||||
|
|
||||||
function handleSend() {
|
function handleSend() {
|
||||||
const text = inputText.value.trim()
|
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)
|
chatStore.sendMessage(text, attachments.value.length > 0 ? attachments.value : undefined)
|
||||||
inputText.value = ''
|
inputText.value = ''
|
||||||
|
|||||||
@@ -595,15 +595,11 @@ onBeforeUnmount(() => {
|
|||||||
<MarkdownRenderer v-else-if="message.content" :content="message.content" />
|
<MarkdownRenderer v-else-if="message.content" :content="message.content" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Render assistant/system message content -->
|
<!-- Render assistant message content -->
|
||||||
<MarkdownRenderer
|
<MarkdownRenderer
|
||||||
v-if="message.role === 'assistant' && message.content && !parsedThinking.body"
|
v-if="message.role === 'assistant' && message.content && !parsedThinking.body"
|
||||||
:content="message.content"
|
: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 v-if="message.isStreaming && !message.content" class="streaming-dots">
|
||||||
<span></span><span></span><span></span>
|
<span></span><span></span><span></span>
|
||||||
|
|||||||
@@ -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 { deleteSession as deleteSessionApi, fetchSession, fetchSessions, type HermesMessage, type SessionSummary } from '@/api/hermes/sessions'
|
||||||
import { getApiKey } from '@/api/client'
|
import { getApiKey } from '@/api/client'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
@@ -8,7 +8,6 @@ import { useProfilesStore } from './profiles'
|
|||||||
import { useSettingsStore } from './settings'
|
import { useSettingsStore } from './settings'
|
||||||
import { primeCompletionSound, playCompletionSound } from '@/utils/completion-sound'
|
import { primeCompletionSound, playCompletionSound } from '@/utils/completion-sound'
|
||||||
import { detectThinkingBoundary } from '@/utils/thinking-parser'
|
import { detectThinkingBoundary } from '@/utils/thinking-parser'
|
||||||
import { parseApprovalCommand } from '@/utils/approval-commands'
|
|
||||||
|
|
||||||
// Re-export ContentBlock for convenience
|
// Re-export ContentBlock for convenience
|
||||||
export type ContentBlock = ContentBlockImport
|
export type ContentBlock = ContentBlockImport
|
||||||
@@ -497,10 +496,6 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
compressed: e.compressed ?? false,
|
compressed: e.compressed ?? false,
|
||||||
error: e.error,
|
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') {
|
} else if (e.event === 'abort.started') {
|
||||||
setAbortState({ aborting: true, synced: null })
|
setAbortState({ aborting: true, synced: null })
|
||||||
} else if (e.event === 'abort.completed') {
|
} else if (e.event === 'abort.completed') {
|
||||||
@@ -633,88 +628,6 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
target.updatedAt = Date.now()
|
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() {
|
function primeCompletionBellIfEnabled() {
|
||||||
if (useSettingsStore().display.bell_on_complete) {
|
if (useSettingsStore().display.bell_on_complete) {
|
||||||
primeCompletionSound()
|
primeCompletionSound()
|
||||||
@@ -728,13 +641,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function sendMessage(content: string, attachments?: Attachment[]) {
|
async function sendMessage(content: string, attachments?: Attachment[]) {
|
||||||
const trimmed = content.trim()
|
if ((!content.trim() && !(attachments && attachments.length > 0))) return
|
||||||
if (isStreaming.value) {
|
|
||||||
const sid = activeSessionId.value
|
|
||||||
if (sid && !attachments?.length && submitApprovalCommand(sid, trimmed)) return
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((!trimmed && !(attachments && attachments.length > 0))) return
|
|
||||||
|
|
||||||
primeCompletionBellIfEnabled()
|
primeCompletionBellIfEnabled()
|
||||||
|
|
||||||
@@ -1043,17 +950,6 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'approval.request':
|
|
||||||
case 'approval.requested': {
|
|
||||||
addApprovalRequestMessage(sid, { ...evt, event: 'approval.request' })
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'approval.responded': {
|
|
||||||
addApprovalResponseMessage(sid, evt)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'run.completed': {
|
case 'run.completed': {
|
||||||
const msgs = getSessionMsgs(sid)
|
const msgs = getSessionMsgs(sid)
|
||||||
const lastMsg = activeAssistantMessageId
|
const lastMsg = activeAssistantMessageId
|
||||||
@@ -1465,17 +1361,6 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'approval.request':
|
|
||||||
case 'approval.requested': {
|
|
||||||
addApprovalRequestMessage(sid, { ...evt, event: 'approval.request' })
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'approval.responded': {
|
|
||||||
addApprovalResponseMessage(sid, evt)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'run.completed': {
|
case 'run.completed': {
|
||||||
const hasQueue = (evt as any).queue_remaining > 0
|
const hasQueue = (evt as any).queue_remaining > 0
|
||||||
if (hasQueue) {
|
if (hasQueue) {
|
||||||
@@ -1624,8 +1509,6 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
onCompressionCompleted: (evt) => handleEvent(evt),
|
onCompressionCompleted: (evt) => handleEvent(evt),
|
||||||
onAbortStarted: (evt) => handleEvent(evt),
|
onAbortStarted: (evt) => handleEvent(evt),
|
||||||
onAbortCompleted: (evt) => handleEvent(evt),
|
onAbortCompleted: (evt) => handleEvent(evt),
|
||||||
onApprovalRequest: (evt) => handleEvent(evt),
|
|
||||||
onApprovalResponded: (evt) => handleEvent(evt),
|
|
||||||
onUsageUpdated: (evt) => handleEvent(evt),
|
onUsageUpdated: (evt) => handleEvent(evt),
|
||||||
onRunQueued: (evt) => handleEvent(evt),
|
onRunQueued: (evt) => handleEvent(evt),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -149,8 +149,6 @@ interface SessionMessage {
|
|||||||
codex_reasoning_items?: string | null
|
codex_reasoning_items?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApprovalChoice = 'once' | 'session' | 'always' | 'deny'
|
|
||||||
|
|
||||||
interface QueuedRun {
|
interface QueuedRun {
|
||||||
queue_id: string
|
queue_id: string
|
||||||
input: string | ContentBlock[]
|
input: string | ContentBlock[]
|
||||||
@@ -270,13 +268,6 @@ export class ChatRunSocket {
|
|||||||
void this.handleAbort(socket, data.session_id)
|
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[] {
|
private handleMessage(messages: SessionMessage[], sid: string): any[] {
|
||||||
let _messages = []
|
let _messages = []
|
||||||
@@ -890,7 +881,6 @@ export class ChatRunSocket {
|
|||||||
|
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||||
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
|
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)
|
// Convert input from ContentBlock[] to Anthropic format (with base64 images)
|
||||||
if (isContentBlockArray(input)) {
|
if (isContentBlockArray(input)) {
|
||||||
body.input = await convertContentBlocks(input)
|
body.input = await convertContentBlocks(input)
|
||||||
@@ -950,13 +940,12 @@ export class ChatRunSocket {
|
|||||||
const eventsUrl = new URL(`${upstream}/v1/runs/${runId}/events`)
|
const eventsUrl = new URL(`${upstream}/v1/runs/${runId}/events`)
|
||||||
|
|
||||||
// Use Authorization header instead of query parameter for better compatibility
|
// 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, {
|
fetch: (url: string, init: any = {}) => fetch(url, {
|
||||||
...init,
|
...init,
|
||||||
headers: {
|
headers: {
|
||||||
...(init.headers || {}),
|
...(init.headers || {}),
|
||||||
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
Authorization: `Bearer ${apiKey}`,
|
||||||
...(session_id ? { 'X-Hermes-Session-Key': this.getGatewaySessionKey(session_id) } : {}),
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
} : {}
|
} : {}
|
||||||
@@ -978,19 +967,6 @@ export class ChatRunSocket {
|
|||||||
logger.info('[chat-run-socket] upstream event: %s', parsed.event)
|
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
|
// Track messages into sessionMap
|
||||||
if (session_id) {
|
if (session_id) {
|
||||||
const state = this.sessionMap.get(session_id)
|
const state = this.sessionMap.get(session_id)
|
||||||
@@ -1274,13 +1250,8 @@ export class ChatRunSocket {
|
|||||||
const apiKey = this.gatewayManager.getApiKey(profile) || undefined
|
const apiKey = this.gatewayManager.getApiKey(profile) || undefined
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||||
'Content-Type': 'application/json',
|
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
|
||||||
'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')
|
logger.info({ sessionId, runId, upstream }, '[chat-run-socket][abort] calling upstream stop')
|
||||||
await fetch(`${upstream}/v1/runs/${runId}/stop`, {
|
await fetch(`${upstream}/v1/runs/${runId}/stop`, {
|
||||||
@@ -1311,77 +1282,6 @@ export class ChatRunSocket {
|
|||||||
await this.markAbortCompleted(socket, sessionId, runId)
|
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<string, string> = {
|
|
||||||
'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 */
|
/** 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 }) {
|
private async markCompleted(socket: Socket, sessionId: string, _info: { event: string; run_id?: string }) {
|
||||||
const state = this.sessionMap.get(sessionId)
|
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)
|
logger.info('[chat-run-socket] enqueued ephemeral session %s for deletion', hermesSessionId)
|
||||||
} catch { /* best-effort */ }
|
} catch { /* best-effort */ }
|
||||||
}
|
}
|
||||||
private async getApprovalCapabilityError(upstream: string, headers: Record<string, string>): Promise<string | null> {
|
|
||||||
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 */
|
/** Get or create session state in sessionMap */
|
||||||
private getOrCreateSession(sessionId: string): SessionState {
|
private getOrCreateSession(sessionId: string): SessionState {
|
||||||
let state = this.sessionMap.get(sessionId)
|
let state = this.sessionMap.get(sessionId)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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<typeof import('naive-ui')>()
|
|
||||||
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: '<button :disabled="disabled" @click="$emit(\'click\')"><slot name="icon"/><slot /></button>',
|
|
||||||
},
|
|
||||||
NTooltip: {
|
|
||||||
template: '<div><slot name="trigger"/><slot /></div>',
|
|
||||||
},
|
|
||||||
NSwitch: {
|
|
||||||
props: ['value'],
|
|
||||||
emits: ['update:value'],
|
|
||||||
template: '<input type="checkbox" />',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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 () => {
|
it('renders highlighted code blocks for tool arguments and tool results', async () => {
|
||||||
const wrapper = mount(MessageItem, {
|
const wrapper = mount(MessageItem, {
|
||||||
props: {
|
props: {
|
||||||
|
|||||||
@@ -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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -2,14 +2,12 @@
|
|||||||
* Tests for session-sync service
|
* Tests for session-sync service
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
import { getDb } from '../../packages/server/src/db/index'
|
import { getDb, ensureTable } 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'
|
import { syncAllHermesSessionsOnStartup } from '../../packages/server/src/services/hermes/session-sync'
|
||||||
|
|
||||||
describe('session-sync', () => {
|
describe('session-sync', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Reset database before each test
|
// Reset database before each test
|
||||||
initAllStores()
|
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
if (db) {
|
if (db) {
|
||||||
db.exec('DELETE FROM sessions')
|
db.exec('DELETE FROM sessions')
|
||||||
|
|||||||
Reference in New Issue
Block a user