Files
hermes-ui/tests/client/chat-store-compression-state.test.ts
T
toor 1e9ea89d86
Build / build (push) Canceled after 0s
NPM Lockfile Check / npm ci --ignore-scripts (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
chore: bootstrap hermes-ui skeleton from 0.6.29 snapshot
Import the client, server, skills, tests, and build scripts as the starting
point for the rebuild, then trim the surfaces this project does not ship:
Electron desktop packaging, the marketing website, and the ESP32 firmware
tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows.

Rename the package and CLI entry points to hermes-ui, drop the prepare hook so
npm ci no longer triggers a build, and drop the removed tsconfig and nodemon
references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs
so the documented package map and hard rules match this repository.

reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
2026-10-16 10:34:35 +08:00

696 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()),
onSessionWorkspaceUpdated: 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 })
})
})