56c7b59eaf
* 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
131 lines
4.5 KiB
TypeScript
131 lines
4.5 KiB
TypeScript
// @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)
|
|
})
|
|
})
|