Files
hermes-web-ui/tests/client/chat-store-compression-state.test.ts
2026-07-02 14:36:25 +08:00

695 lines
24 KiB
TypeScript

// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
const chatApi = vi.hoisted(() => ({
startRunViaSocket: vi.fn(),
resumeSession: vi.fn(),
registerSessionHandlers: vi.fn(),
unregisterSessionHandlers: vi.fn(),
}))
vi.mock('@/api/hermes/chat', () => ({
startRunViaSocket: chatApi.startRunViaSocket,
resumeSession: chatApi.resumeSession,
registerSessionHandlers: chatApi.registerSessionHandlers,
unregisterSessionHandlers: chatApi.unregisterSessionHandlers,
getChatRunSocket: vi.fn(() => ({ emit: vi.fn() })),
respondToolApproval: vi.fn(),
respondClarify: vi.fn(),
onPeerUserMessage: vi.fn(() => vi.fn()),
onSessionCommand: vi.fn(() => vi.fn()),
onSessionTitleUpdated: vi.fn(() => vi.fn()),
}))
vi.mock('@/api/client', () => ({
getActiveProfileName: () => 'default',
hasApiKey: () => false,
}))
vi.mock('@/api/hermes/sessions', () => ({
archiveSession: vi.fn(),
deleteSession: vi.fn(),
fetchSession: vi.fn(),
fetchSessions: vi.fn(),
fetchWorkspaceRunChangesForSession: vi.fn(async () => []),
fetchWorkspaceRunChangeFile: vi.fn(async () => null),
setSessionModel: vi.fn(),
}))
vi.mock('@/api/hermes/download', () => ({
getDownloadUrl: (_path: string, name: string) => `/download/${name}`,
}))
vi.mock('@/api/hermes/system', () => ({
checkHealth: vi.fn(),
fetchAvailableModels: vi.fn(),
addCustomModel: vi.fn(),
removeCustomModel: vi.fn(),
updateDefaultModel: vi.fn(),
updateModelVisibility: vi.fn(),
triggerUpdate: vi.fn(),
updateModelAlias: vi.fn(),
}))
vi.mock('@/utils/completion-sound', () => ({
primeCompletionSound: vi.fn(),
playCompletionSound: vi.fn(),
}))
import { useChatStore, type Message, type Session } from '@/stores/hermes/chat'
function makeSession(id: string): Session {
return {
id,
title: id,
messages: [],
createdAt: Date.now(),
updatedAt: Date.now(),
}
}
describe('chat store compression state', () => {
let handlers: any
beforeEach(() => {
handlers = undefined
vi.resetAllMocks()
setActivePinia(createPinia())
chatApi.startRunViaSocket.mockReturnValue({ abort: vi.fn() })
chatApi.resumeSession.mockImplementation((sessionId: string, onResumed: (data: any) => void) => {
onResumed({
session_id: sessionId,
messages: [],
isWorking: sessionId === 'session-1',
events: [],
})
return {} as any
})
chatApi.registerSessionHandlers.mockImplementation((_sessionId: string, registeredHandlers: any) => {
handlers = registeredHandlers
return vi.fn()
})
})
it('does not show a background session compression indicator in the active session', async () => {
const store = useChatStore()
store.sessions = [makeSession('session-1'), makeSession('session-2')]
await store.switchSession('session-1')
const sessionHandlers = chatApi.registerSessionHandlers.mock.calls.find(call => call[0] === 'session-1')?.[1]
expect(sessionHandlers).toBeTruthy()
await store.switchSession('session-2')
sessionHandlers.onCompressionStarted({
event: 'compression.started',
session_id: 'session-1',
message_count: 6,
token_count: 1234,
})
expect(store.activeSessionId).toBe('session-2')
expect(store.compressionState).toBeNull()
await store.switchSession('session-1')
expect(store.compressionState).toEqual(expect.objectContaining({
compressing: true,
messageCount: 6,
beforeTokens: 1234,
}))
})
it('surfaces non-terminal reattach warnings replayed in a non-working resume payload', async () => {
chatApi.resumeSession.mockImplementationOnce((sessionId: string, onResumed: (data: any) => void) => {
onResumed({
session_id: sessionId,
messages: [],
isWorking: false,
events: [{
event: 'run.reattach_failed',
data: {
event: 'run.reattach_failed',
session_id: sessionId,
error: 'connect ECONNREFUSED configured endpoint',
message: 'Unable to confirm Agent Bridge status while resuming: connect ECONNREFUSED configured endpoint',
text: 'Unable to confirm Agent Bridge status while resuming: connect ECONNREFUSED configured endpoint',
},
}],
})
return {} as any
})
const store = useChatStore()
store.sessions = [makeSession('session-reattach')]
await store.switchSession('session-reattach')
await nextTick()
expect(store.activeSession?.messages).toEqual(expect.arrayContaining([
expect.objectContaining({
role: 'system',
commandAction: 'agent.event',
content: 'Unable to confirm Agent Bridge status while resuming: connect ECONNREFUSED configured endpoint',
}),
]))
})
it('preserves streamed content when run.completed parsed_content is blank', async () => {
const store = useChatStore()
store.sessions = [makeSession('session-1')]
await store.switchSession('session-1')
store.activeSession!.messages = [{
id: 'a1',
role: 'assistant',
content: 'final answer',
timestamp: Date.now(),
isStreaming: true,
} as any]
handlers.onRunCompleted({ event: 'run.completed', parsed_content: '', output: '', run_id: 'run-1' })
await nextTick()
const assistant = store.activeSession?.messages.find((message: Message) => message.id === 'a1')
expect(assistant?.content).toBe('final answer')
expect(assistant?.isStreaming).toBe(false)
expect(store.activeSession?.messages.some(
(message: Message) => message.role === 'system' && message.content.includes('Agent returned no output'),
)).toBe(false)
})
it('renders parsed_content-only completion as a new assistant message and auto-plays it', async () => {
vi.useFakeTimers()
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
const store = useChatStore()
store.sessions = [makeSession('session-1')]
await store.switchSession('session-1')
store.setAutoPlaySpeech(true)
handlers.onRunCompleted({
event: 'run.completed',
parsed_content: 'final answer',
output: '',
run_id: 'run-parsed-only',
})
await nextTick()
await vi.advanceTimersByTimeAsync(300)
const assistantMessages = store.activeSession?.messages.filter((message: Message) => message.role === 'assistant') ?? []
expect(assistantMessages).toHaveLength(1)
expect(assistantMessages[0]?.content).toBe('final answer')
expect(store.activeSession?.messages.some(
(message: Message) => message.role === 'system' && message.content.includes('Agent returned no output'),
)).toBe(false)
expect(dispatchSpy).toHaveBeenCalledTimes(1)
const autoPlayEvent = dispatchSpy.mock.calls[0][0] as CustomEvent<{ messageId: string; content: string }>
expect(autoPlayEvent.type).toBe('auto-play-speech')
expect(autoPlayEvent.detail.content).toBe('final answer')
expect(autoPlayEvent.detail.messageId).toBe(assistantMessages[0]?.id)
vi.useRealTimers()
})
it('does not treat an older assistant message as output for a blank run.completed event', async () => {
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
const store = useChatStore()
store.sessions = [makeSession('session-1')]
await store.switchSession('session-1')
store.setAutoPlaySpeech(true)
store.activeSession!.messages = [{
id: 'old-a1',
role: 'assistant',
content: 'previous answer',
timestamp: Date.now(),
isStreaming: false,
} as any]
handlers.onRunCompleted({ event: 'run.completed', parsed_content: '', output: '', run_id: 'run-2' })
await nextTick()
expect(store.activeSession?.messages.find((message: Message) => message.id === 'old-a1')?.content).toBe('previous answer')
expect(store.activeSession?.messages.some(
(message: Message) => message.role === 'system' && message.content.includes('Agent returned no output'),
)).toBe(true)
expect(dispatchSpy).not.toHaveBeenCalled()
})
it('renders parsed_content-only completion as a new assistant message after reconnect resume and auto-plays it', async () => {
vi.useFakeTimers()
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
const store = useChatStore()
const session = makeSession('session-1')
store.sessions = [session]
store.activeSessionId = 'session-1'
store.activeSession = session
store.setAutoPlaySpeech(true)
await store.sendMessage('resume run')
const onEvent = chatApi.startRunViaSocket.mock.calls[0][1] as (event: any) => void
const reconnectOptions = chatApi.startRunViaSocket.mock.calls[0][5] as { onReconnectResume: (data: any) => void }
reconnectOptions.onReconnectResume({
session_id: 'session-1',
isWorking: true,
messages: [],
events: [],
})
onEvent({
event: 'run.completed',
session_id: 'session-1',
parsed_content: 'final answer',
output: '',
run_id: 'run-reconnect-parsed-only',
})
await nextTick()
await vi.advanceTimersByTimeAsync(300)
const assistantMessages = store.activeSession?.messages.filter((message: Message) => message.role === 'assistant') ?? []
expect(assistantMessages).toHaveLength(1)
expect(assistantMessages[0]?.content).toBe('final answer')
expect(store.activeSession?.messages.some(
(message: Message) => message.role === 'system' && message.content.includes('Agent returned no output'),
)).toBe(false)
expect(dispatchSpy).toHaveBeenCalledTimes(1)
const autoPlayEvent = dispatchSpy.mock.calls[0][0] as CustomEvent<{ messageId: string; content: string }>
expect(autoPlayEvent.type).toBe('auto-play-speech')
expect(autoPlayEvent.detail.content).toBe('final answer')
expect(autoPlayEvent.detail.messageId).toBe(assistantMessages[0]?.id)
vi.useRealTimers()
})
it('renders object-shaped run failure errors with their message text', async () => {
const store = useChatStore()
const session = makeSession('session-1')
store.sessions = [session]
store.activeSessionId = 'session-1'
store.activeSession = session
await store.sendMessage('run claude')
const onEvent = chatApi.startRunViaSocket.mock.calls[0][1] as (event: any) => void
onEvent({
event: 'run.failed',
session_id: 'session-1',
error: {
message: 'spawn claude ENOENT',
code: 'ENOENT',
},
})
const errorMessage = store.activeSession?.messages.find(
(message: Message) => message.role === 'assistant' && message.systemType === 'error',
)
expect(errorMessage?.content).toBe('Error: spawn claude ENOENT')
expect(errorMessage?.content).not.toContain('[object Object]')
})
it('appends post-reconnect deltas to a genuine resumed in-flight assistant instead of duplicating it', async () => {
const store = useChatStore()
const session = makeSession('session-1')
store.sessions = [session]
store.activeSessionId = 'session-1'
store.activeSession = session
await store.sendMessage('resume run')
const onEvent = chatApi.startRunViaSocket.mock.calls[0][1] as (event: any) => void
const reconnectOptions = chatApi.startRunViaSocket.mock.calls[0][5] as { onReconnectResume: (data: any) => void }
reconnectOptions.onReconnectResume({
session_id: 'session-1',
isWorking: true,
messages: [{
id: 'current-a1',
role: 'assistant',
content: 'partial answer',
timestamp: Date.now() / 1000,
finish_reason: null,
runMarker: 'cli_run_current',
}],
events: [],
})
onEvent({
event: 'message.delta',
session_id: 'session-1',
delta: ' continued',
run_id: 'run-reconnect-streaming',
})
await nextTick()
const assistantMessages = store.activeSession?.messages.filter((message: Message) => message.role === 'assistant') ?? []
expect(assistantMessages).toHaveLength(1)
expect(assistantMessages[0]).toEqual(expect.objectContaining({
id: 'current-a1',
content: 'partial answer continued',
isStreaming: true,
}))
})
it('closes a genuine resumed in-flight assistant on blank completion without swallowed-error or replay', async () => {
vi.useFakeTimers()
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
const store = useChatStore()
const session = makeSession('session-1')
store.sessions = [session]
store.activeSessionId = 'session-1'
store.activeSession = session
store.setAutoPlaySpeech(true)
await store.sendMessage('resume run')
const onEvent = chatApi.startRunViaSocket.mock.calls[0][1] as (event: any) => void
const reconnectOptions = chatApi.startRunViaSocket.mock.calls[0][5] as { onReconnectResume: (data: any) => void }
reconnectOptions.onReconnectResume({
session_id: 'session-1',
isWorking: true,
messages: [{
id: 'current-a1',
role: 'assistant',
content: 'partial answer',
timestamp: Date.now() / 1000,
finish_reason: null,
runMarker: 'cli_run_current',
}],
events: [],
})
onEvent({
event: 'run.completed',
session_id: 'session-1',
parsed_content: '',
output: '',
run_id: 'run-reconnect-blank-resumed-current',
})
await nextTick()
await vi.advanceTimersByTimeAsync(300)
const assistantMessages = store.activeSession?.messages.filter((message: Message) => message.role === 'assistant') ?? []
expect(assistantMessages).toHaveLength(1)
expect(assistantMessages[0]).toEqual(expect.objectContaining({
id: 'current-a1',
content: 'partial answer',
isStreaming: false,
}))
expect(store.activeSession?.messages.some(
(message: Message) => message.role === 'system' && message.content.includes('Agent returned no output'),
)).toBe(false)
expect(dispatchSpy).not.toHaveBeenCalled()
vi.useRealTimers()
})
it('finalizes a genuine resumed in-flight assistant with parsed_content instead of appending a second row', async () => {
vi.useFakeTimers()
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
const store = useChatStore()
const session = makeSession('session-1')
store.sessions = [session]
store.activeSessionId = 'session-1'
store.activeSession = session
store.setAutoPlaySpeech(true)
await store.sendMessage('resume run')
const onEvent = chatApi.startRunViaSocket.mock.calls[0][1] as (event: any) => void
const reconnectOptions = chatApi.startRunViaSocket.mock.calls[0][5] as { onReconnectResume: (data: any) => void }
reconnectOptions.onReconnectResume({
session_id: 'session-1',
isWorking: true,
messages: [{
id: 'current-a1',
role: 'assistant',
content: 'partial answer',
timestamp: Date.now() / 1000,
finish_reason: null,
runMarker: 'cli_run_current',
}],
events: [],
})
onEvent({
event: 'run.completed',
session_id: 'session-1',
parsed_content: 'final answer',
output: '',
run_id: 'run-reconnect-current-parsed-content',
})
await nextTick()
await vi.advanceTimersByTimeAsync(300)
const assistantMessages = store.activeSession?.messages.filter((message: Message) => message.role === 'assistant') ?? []
expect(assistantMessages).toHaveLength(1)
expect(assistantMessages[0]).toEqual(expect.objectContaining({
id: 'current-a1',
content: 'final answer',
isStreaming: false,
}))
expect(dispatchSpy).toHaveBeenCalledTimes(1)
const autoPlayEvent = dispatchSpy.mock.calls[0][0] as CustomEvent<{ messageId: string; content: string }>
expect(autoPlayEvent.type).toBe('auto-play-speech')
expect(autoPlayEvent.detail.content).toBe('final answer')
expect(autoPlayEvent.detail.messageId).toBe('current-a1')
vi.useRealTimers()
})
it('does not auto-play resumed assistant history again on blank completion after reconnect', async () => {
vi.useFakeTimers()
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
const store = useChatStore()
const session = makeSession('session-1')
session.messages = [{
id: 'old-a1',
role: 'assistant',
content: 'previous answer',
timestamp: Date.now(),
isStreaming: false,
} as any]
store.sessions = [session]
store.activeSessionId = 'session-1'
store.activeSession = session
store.setAutoPlaySpeech(true)
await store.sendMessage('resume run')
const onEvent = chatApi.startRunViaSocket.mock.calls[0][1] as (event: any) => void
const reconnectOptions = chatApi.startRunViaSocket.mock.calls[0][5] as { onReconnectResume: (data: any) => void }
reconnectOptions.onReconnectResume({
session_id: 'session-1',
isWorking: true,
messages: [{
id: 'old-a1',
role: 'assistant',
content: 'previous answer',
timestamp: Date.now() / 1000,
finish_reason: 'stop',
}],
events: [],
})
onEvent({
event: 'run.completed',
session_id: 'session-1',
parsed_content: '',
output: '',
run_id: 'run-reconnect-blank',
})
await nextTick()
await vi.advanceTimersByTimeAsync(300)
expect(store.activeSession?.messages.find((message: Message) => message.id === 'old-a1')?.content).toBe('previous answer')
expect(dispatchSpy).not.toHaveBeenCalled()
vi.useRealTimers()
})
it('preserves resumed assistant history when parsed_content arrives after reconnect and auto-plays the new row', async () => {
vi.useFakeTimers()
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
const store = useChatStore()
const session = makeSession('session-1')
session.messages = [{
id: 'old-a1',
role: 'assistant',
content: 'previous answer',
timestamp: Date.now(),
isStreaming: false,
} as any]
store.sessions = [session]
store.activeSessionId = 'session-1'
store.activeSession = session
store.setAutoPlaySpeech(true)
await store.sendMessage('resume run')
const onEvent = chatApi.startRunViaSocket.mock.calls[0][1] as (event: any) => void
const reconnectOptions = chatApi.startRunViaSocket.mock.calls[0][5] as { onReconnectResume: (data: any) => void }
reconnectOptions.onReconnectResume({
session_id: 'session-1',
isWorking: true,
messages: [{
id: 'old-a1',
role: 'assistant',
content: 'previous answer',
timestamp: Date.now() / 1000,
}],
events: [],
})
onEvent({
event: 'run.completed',
session_id: 'session-1',
parsed_content: 'final answer',
output: '',
run_id: 'run-reconnect-historical-parsed-content',
})
await nextTick()
await vi.advanceTimersByTimeAsync(300)
const assistantMessages = store.activeSession?.messages.filter((message: Message) => message.role === 'assistant') ?? []
expect(assistantMessages).toHaveLength(2)
expect(assistantMessages[0]).toEqual(expect.objectContaining({
id: 'old-a1',
content: 'previous answer',
}))
expect(assistantMessages[1]).toEqual(expect.objectContaining({
content: 'final answer',
}))
expect(dispatchSpy).toHaveBeenCalledTimes(1)
const autoPlayEvent = dispatchSpy.mock.calls[0][0] as CustomEvent<{ messageId: string; content: string }>
expect(autoPlayEvent.type).toBe('auto-play-speech')
expect(autoPlayEvent.detail.content).toBe('final answer')
expect(autoPlayEvent.detail.messageId).toBe(assistantMessages[1]?.id)
expect(autoPlayEvent.detail.messageId).not.toBe('old-a1')
vi.useRealTimers()
})
it('still auto-plays genuinely new assistant text after reconnect resume', async () => {
vi.useFakeTimers()
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
const store = useChatStore()
const session = makeSession('session-1')
session.messages = [{
id: 'old-a1',
role: 'assistant',
content: 'previous answer',
timestamp: Date.now(),
isStreaming: false,
} as any]
store.sessions = [session]
store.activeSessionId = 'session-1'
store.activeSession = session
store.setAutoPlaySpeech(true)
await store.sendMessage('resume run')
const onEvent = chatApi.startRunViaSocket.mock.calls[0][1] as (event: any) => void
const reconnectOptions = chatApi.startRunViaSocket.mock.calls[0][5] as { onReconnectResume: (data: any) => void }
reconnectOptions.onReconnectResume({
session_id: 'session-1',
isWorking: true,
messages: [{
id: 'old-a1',
role: 'assistant',
content: 'previous answer',
timestamp: Date.now() / 1000,
finish_reason: 'stop',
}],
events: [],
})
onEvent({
event: 'run.completed',
session_id: 'session-1',
output: 'new answer',
run_id: 'run-reconnect-new-output',
})
await nextTick()
await vi.advanceTimersByTimeAsync(300)
expect(dispatchSpy).toHaveBeenCalledTimes(1)
const autoPlayEvent = dispatchSpy.mock.calls[0][0] as CustomEvent<{ messageId: string; content: string }>
expect(autoPlayEvent.type).toBe('auto-play-speech')
expect(autoPlayEvent.detail.content).toBe('new answer')
expect(autoPlayEvent.detail.messageId).not.toBe('old-a1')
vi.useRealTimers()
})
it('does not auto-play an older assistant message when a run only emits tool output', async () => {
vi.useFakeTimers()
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
const store = useChatStore()
store.sessions = [makeSession('session-1')]
await store.switchSession('session-1')
store.setAutoPlaySpeech(true)
store.activeSession!.messages = [{
id: 'old-a1',
role: 'assistant',
content: 'previous answer',
timestamp: Date.now(),
isStreaming: false,
} as any]
handlers.onToolStarted({ event: 'tool.started', tool: 'search', tool_call_id: 'tool-1', run_id: 'run-3' })
handlers.onToolCompleted({ event: 'tool.completed', tool_call_id: 'tool-1', output: { ok: true }, run_id: 'run-3' })
handlers.onRunCompleted({ event: 'run.completed', parsed_content: '', output: '', run_id: 'run-3' })
await nextTick()
await vi.advanceTimersByTimeAsync(300)
expect(dispatchSpy).not.toHaveBeenCalled()
vi.useRealTimers()
})
it('auto-plays only the new assistant message when the current run produces one', async () => {
vi.useFakeTimers()
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
const store = useChatStore()
store.sessions = [makeSession('session-1')]
await store.switchSession('session-1')
store.setAutoPlaySpeech(true)
store.activeSession!.messages = [{
id: 'old-a1',
role: 'assistant',
content: 'previous answer',
timestamp: Date.now(),
isStreaming: false,
} as any]
handlers.onMessageDelta({ event: 'message.delta', delta: 'new answer', run_id: 'run-4' })
handlers.onRunCompleted({ event: 'run.completed', parsed_content: 'new answer', output: '', run_id: 'run-4' })
await nextTick()
await vi.advanceTimersByTimeAsync(300)
expect(dispatchSpy).toHaveBeenCalledTimes(1)
const autoPlayEvent = dispatchSpy.mock.calls[0][0] as CustomEvent<{ messageId: string; content: string }>
expect(autoPlayEvent.type).toBe('auto-play-speech')
expect(autoPlayEvent.detail.content).toBe('new answer')
expect(autoPlayEvent.detail.messageId).not.toBe('old-a1')
vi.useRealTimers()
})
it('preserves non-string tool.completed outputs in live session handlers', async () => {
const store = useChatStore()
store.sessions = [makeSession('session-1')]
await store.switchSession('session-1')
store.activeSession!.messages = [{
id: 'tool-1',
role: 'tool',
content: '',
timestamp: Date.now(),
toolCallId: 'call-1',
toolStatus: 'running',
} as any]
handlers.onToolCompleted({ event: 'tool.completed', tool_call_id: 'call-1', output: { ok: true }, run_id: 'run-1' })
await nextTick()
const tool = store.activeSession?.messages.find((message: Message) => message.id === 'tool-1')
expect(tool?.toolStatus).toBe('done')
expect(tool?.toolResult).toEqual({ ok: true })
})
})