initialize Electron desktop app and remove unused files
This commit is contained in:
@@ -0,0 +1,540 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
const chatApi = vi.hoisted(() => ({
|
||||
startRunViaSocket: vi.fn(),
|
||||
socketEmit: vi.fn(),
|
||||
}))
|
||||
const sessionsApi = vi.hoisted(() => ({
|
||||
archiveSession: vi.fn(),
|
||||
deleteSession: vi.fn(),
|
||||
fetchSessionMessagesPage: vi.fn(),
|
||||
fetchSessions: vi.fn(),
|
||||
setSessionModel: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/hermes/chat', () => ({
|
||||
startRunViaSocket: chatApi.startRunViaSocket,
|
||||
resumeSession: vi.fn(),
|
||||
registerSessionHandlers: vi.fn(),
|
||||
unregisterSessionHandlers: vi.fn(),
|
||||
getChatRunSocket: vi.fn(() => ({ emit: chatApi.socketEmit })),
|
||||
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', () => ({
|
||||
deleteSession: sessionsApi.deleteSession,
|
||||
fetchSessionMessagesPage: sessionsApi.fetchSessionMessagesPage,
|
||||
fetchSessions: sessionsApi.fetchSessions,
|
||||
fetchWorkspaceRunChangesForSession: vi.fn(async () => []),
|
||||
fetchWorkspaceRunChangeFile: vi.fn(async () => null),
|
||||
setSessionModel: sessionsApi.setSessionModel,
|
||||
}))
|
||||
|
||||
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 Session } from '@/stores/hermes/chat'
|
||||
import type { RunEvent } from '@/api/hermes/chat'
|
||||
|
||||
function makeSession(): Session {
|
||||
return {
|
||||
id: 'session-1',
|
||||
title: 'session',
|
||||
messages: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('chat store reasoning/tool boundaries', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
chatApi.startRunViaSocket.mockReturnValue({ abort: vi.fn() })
|
||||
sessionsApi.deleteSession.mockResolvedValue(true)
|
||||
sessionsApi.setSessionModel.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
it('merges reasoning across tool cycles without appending post-tool text before the tool', async () => {
|
||||
const store = useChatStore()
|
||||
const session = makeSession()
|
||||
store.sessions = [session]
|
||||
store.activeSessionId = 'session-1'
|
||||
store.activeSession = session
|
||||
|
||||
await store.sendMessage('run a tool')
|
||||
|
||||
const onEvent = chatApi.startRunViaSocket.mock.calls[0][1] as (event: RunEvent) => void
|
||||
onEvent({ event: 'run.started', session_id: 'session-1' })
|
||||
onEvent({ event: 'reasoning.delta', session_id: 'session-1', delta: 'think before. ' })
|
||||
onEvent({ event: 'message.delta', session_id: 'session-1', delta: 'Before tool.' })
|
||||
onEvent({
|
||||
event: 'tool.started',
|
||||
session_id: 'session-1',
|
||||
tool_call_id: 'tool-1',
|
||||
tool: 'shell',
|
||||
arguments: '{}',
|
||||
} as RunEvent)
|
||||
onEvent({ event: 'reasoning.delta', session_id: 'session-1', delta: 'think after. ' })
|
||||
onEvent({
|
||||
event: 'tool.completed',
|
||||
session_id: 'session-1',
|
||||
tool_call_id: 'tool-1',
|
||||
output: 'tool output',
|
||||
} as RunEvent)
|
||||
onEvent({ event: 'message.delta', session_id: 'session-1', delta: 'After tool.' })
|
||||
|
||||
expect(store.messages.map(message => message.role)).toEqual([
|
||||
'user',
|
||||
'assistant',
|
||||
'tool',
|
||||
'assistant',
|
||||
])
|
||||
expect(store.messages[1]).toEqual(expect.objectContaining({
|
||||
role: 'assistant',
|
||||
content: 'Before tool.',
|
||||
reasoning: 'think before. think after. ',
|
||||
isStreaming: false,
|
||||
}))
|
||||
expect(store.messages[2]).toEqual(expect.objectContaining({
|
||||
role: 'tool',
|
||||
toolStatus: 'done',
|
||||
toolResult: 'tool output',
|
||||
}))
|
||||
expect(store.messages[3]).toEqual(expect.objectContaining({
|
||||
role: 'assistant',
|
||||
content: 'After tool.',
|
||||
isStreaming: true,
|
||||
}))
|
||||
})
|
||||
|
||||
it('renders MoA reference and aggregating events as tools before the final assistant message', async () => {
|
||||
const store = useChatStore()
|
||||
const session = makeSession()
|
||||
store.sessions = [session]
|
||||
store.activeSessionId = 'session-1'
|
||||
store.activeSession = session
|
||||
|
||||
await store.sendMessage('你好')
|
||||
|
||||
const onEvent = chatApi.startRunViaSocket.mock.calls[0][1] as (event: RunEvent) => void
|
||||
onEvent({
|
||||
event: 'session.command',
|
||||
session_id: 'session-1',
|
||||
action: 'moa',
|
||||
started: true,
|
||||
terminal: false,
|
||||
preset: 'default',
|
||||
moa: {
|
||||
preset: 'default',
|
||||
reference_models: ['xai-oauth:grok-4.3', 'custom:fun-codex:gpt-5.5'],
|
||||
aggregator: 'glm:glm-5.2',
|
||||
},
|
||||
} as RunEvent)
|
||||
onEvent({ event: 'run.started', session_id: 'session-1' })
|
||||
onEvent({
|
||||
event: 'moa.reference',
|
||||
session_id: 'session-1',
|
||||
label: 'grok-4.3',
|
||||
text: 'reference answer',
|
||||
index: 1,
|
||||
count: 2,
|
||||
})
|
||||
onEvent({
|
||||
event: 'moa.aggregating',
|
||||
session_id: 'session-1',
|
||||
aggregator: 'deepseek-v4-pro',
|
||||
})
|
||||
onEvent({ event: 'run.completed', session_id: 'session-1', output: 'final answer' })
|
||||
|
||||
expect(store.messages.map(message => message.role)).toEqual(['user', 'tool', 'tool', 'assistant'])
|
||||
expect(store.messages[1]).toEqual(expect.objectContaining({
|
||||
role: 'tool',
|
||||
toolName: 'moa_reference',
|
||||
toolPreview: '1/2 grok-4.3',
|
||||
toolStatus: 'done',
|
||||
toolResult: 'reference answer',
|
||||
}))
|
||||
expect(store.messages[2]).toEqual(expect.objectContaining({
|
||||
role: 'tool',
|
||||
toolName: 'moa_aggregating',
|
||||
toolPreview: 'deepseek-v4-pro',
|
||||
toolStatus: 'done',
|
||||
toolArgs: { aggregator: 'deepseek-v4-pro' },
|
||||
}))
|
||||
expect(store.messages[3]).toEqual(expect.objectContaining({
|
||||
role: 'assistant',
|
||||
content: 'final answer',
|
||||
}))
|
||||
expect(store.messages.some(message => message.role === 'system' && message.content.includes('Agent returned no output'))).toBe(false)
|
||||
})
|
||||
|
||||
it('does not add a front-end MoA discussion placeholder from session.command start', async () => {
|
||||
const store = useChatStore()
|
||||
const session = makeSession()
|
||||
store.sessions = [session]
|
||||
store.activeSessionId = 'session-1'
|
||||
store.activeSession = session
|
||||
|
||||
await store.sendMessage('你好')
|
||||
|
||||
const onEvent = chatApi.startRunViaSocket.mock.calls[0][1] as (event: RunEvent) => void
|
||||
onEvent({
|
||||
event: 'session.command',
|
||||
session_id: 'session-1',
|
||||
action: 'moa',
|
||||
started: true,
|
||||
terminal: false,
|
||||
preset: 'default',
|
||||
moa: { preset: 'default', reference_models: ['a:model'], aggregator: 'agg:model' },
|
||||
} as RunEvent)
|
||||
|
||||
expect(store.messages.some(message => message.toolName === 'moa_discussion')).toBe(false)
|
||||
expect(store.messages.map(message => message.role)).toEqual(['user'])
|
||||
})
|
||||
|
||||
it('restores persisted MoA tool rows from session history', async () => {
|
||||
const store = useChatStore()
|
||||
const session = makeSession()
|
||||
store.sessions = [session]
|
||||
store.activeSessionId = 'session-1'
|
||||
store.activeSession = session
|
||||
sessionsApi.fetchSessionMessagesPage.mockResolvedValue({
|
||||
session: { id: 'session-1', title: 'session' },
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
role: 'command',
|
||||
content: '/moa 你好',
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
role: 'moa',
|
||||
display_role: 'tool',
|
||||
content: JSON.stringify({
|
||||
label: 'xai-oauth:grok-4.3',
|
||||
preview: '1/2 xai-oauth:grok-4.3',
|
||||
text: 'reference answer',
|
||||
index: 1,
|
||||
count: 2,
|
||||
}),
|
||||
tool_name: 'moa_reference',
|
||||
tool_call_id: 'moa:reference:run-1:1',
|
||||
timestamp: 2,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
role: 'moa',
|
||||
display_role: 'tool',
|
||||
content: JSON.stringify({
|
||||
aggregator: 'glm:glm-5.2',
|
||||
preview: 'glm:glm-5.2',
|
||||
text: 'glm:glm-5.2',
|
||||
}),
|
||||
tool_name: 'moa_aggregating',
|
||||
tool_call_id: 'moa:aggregating:run-1',
|
||||
timestamp: 3,
|
||||
},
|
||||
],
|
||||
total: 3,
|
||||
hasMore: false,
|
||||
})
|
||||
|
||||
await store.refreshActiveSession()
|
||||
|
||||
expect(store.messages).toEqual([
|
||||
expect.objectContaining({
|
||||
role: 'command',
|
||||
content: '/moa 你好',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
role: 'tool',
|
||||
toolName: 'moa_reference',
|
||||
toolPreview: '1/2 xai-oauth:grok-4.3',
|
||||
toolResult: 'reference answer',
|
||||
toolStatus: 'done',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
role: 'tool',
|
||||
toolName: 'moa_aggregating',
|
||||
toolPreview: 'glm:glm-5.2',
|
||||
toolResult: 'glm:glm-5.2',
|
||||
toolStatus: 'done',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('settles running tools when the run completes without a tool.completed event', async () => {
|
||||
const store = useChatStore()
|
||||
const session = makeSession()
|
||||
store.sessions = [session]
|
||||
store.activeSessionId = 'session-1'
|
||||
store.activeSession = session
|
||||
|
||||
await store.sendMessage('run pwd')
|
||||
|
||||
const onEvent = chatApi.startRunViaSocket.mock.calls[0][1] as (event: RunEvent) => void
|
||||
onEvent({ event: 'run.started', session_id: 'session-1' })
|
||||
onEvent({
|
||||
event: 'tool.started',
|
||||
session_id: 'session-1',
|
||||
tool_call_id: 'tool-1',
|
||||
tool: 'Bash',
|
||||
arguments: '{"command":"pwd"}',
|
||||
} as RunEvent)
|
||||
expect(store.messages.find(message => message.role === 'tool')).toEqual(expect.objectContaining({
|
||||
toolStatus: 'running',
|
||||
}))
|
||||
|
||||
onEvent({ event: 'run.completed', session_id: 'session-1', output: 'done' })
|
||||
|
||||
expect(store.messages.find(message => message.role === 'tool')).toEqual(expect.objectContaining({
|
||||
toolStatus: 'done',
|
||||
}))
|
||||
})
|
||||
|
||||
it('does not drop repeated small markdown delimiters while streaming', async () => {
|
||||
const store = useChatStore()
|
||||
const session = makeSession()
|
||||
store.sessions = [session]
|
||||
store.activeSessionId = 'session-1'
|
||||
store.activeSession = session
|
||||
|
||||
await store.sendMessage('show code')
|
||||
|
||||
const onEvent = chatApi.startRunViaSocket.mock.calls[0][1] as (event: RunEvent) => void
|
||||
onEvent({ event: 'run.started', session_id: 'session-1' })
|
||||
onEvent({ event: 'message.delta', session_id: 'session-1', delta: '```' })
|
||||
onEvent({ event: 'message.delta', session_id: 'session-1', delta: 'ts' })
|
||||
onEvent({ event: 'message.delta', session_id: 'session-1', delta: '\n' })
|
||||
onEvent({ event: 'message.delta', session_id: 'session-1', delta: 'const value = 1' })
|
||||
onEvent({ event: 'message.delta', session_id: 'session-1', delta: '\n' })
|
||||
onEvent({ event: 'message.delta', session_id: 'session-1', delta: '```' })
|
||||
|
||||
expect(store.messages.find(message => message.role === 'assistant')?.content).toBe([
|
||||
'```ts',
|
||||
'const value = 1',
|
||||
'```',
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
it('queues unknown slash commands in bridge sessions as normal user input', async () => {
|
||||
const store = useChatStore()
|
||||
const session = makeSession()
|
||||
session.source = 'cli'
|
||||
store.sessions = [session]
|
||||
store.activeSessionId = 'session-1'
|
||||
store.activeSession = session
|
||||
|
||||
await store.sendMessage('first input')
|
||||
const onEvent = chatApi.startRunViaSocket.mock.calls[0][1] as (event: RunEvent) => void
|
||||
onEvent({ event: 'run.started', session_id: 'session-1' })
|
||||
|
||||
await store.sendMessage('/terminal pwd')
|
||||
|
||||
expect(chatApi.startRunViaSocket).toHaveBeenCalledTimes(2)
|
||||
expect(store.queuedUserMessages.get('session-1')).toEqual([
|
||||
expect.objectContaining({
|
||||
role: 'user',
|
||||
content: '/terminal pwd',
|
||||
queued: true,
|
||||
systemType: undefined,
|
||||
}),
|
||||
])
|
||||
expect(store.messages).toEqual([
|
||||
expect.objectContaining({
|
||||
role: 'user',
|
||||
content: 'first input',
|
||||
queued: false,
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('queues /moa commands in active bridge sessions without adding a visible command echo', async () => {
|
||||
const store = useChatStore()
|
||||
const session = makeSession()
|
||||
session.source = 'cli'
|
||||
store.sessions = [session]
|
||||
store.activeSessionId = 'session-1'
|
||||
store.activeSession = session
|
||||
|
||||
await store.sendMessage('first input')
|
||||
const onEvent = chatApi.startRunViaSocket.mock.calls[0][1] as (event: RunEvent) => void
|
||||
onEvent({ event: 'run.started', session_id: 'session-1' })
|
||||
|
||||
await store.sendMessage('/moa 你好')
|
||||
|
||||
expect(chatApi.startRunViaSocket).toHaveBeenCalledTimes(2)
|
||||
expect(store.queuedUserMessages.get('session-1')).toEqual([
|
||||
expect.objectContaining({
|
||||
role: 'command',
|
||||
content: '/moa 你好',
|
||||
queued: true,
|
||||
systemType: 'command',
|
||||
}),
|
||||
])
|
||||
expect(store.messages).toEqual([
|
||||
expect.objectContaining({
|
||||
role: 'user',
|
||||
content: 'first input',
|
||||
queued: false,
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('sends unknown slash commands in idle bridge sessions as normal user input', async () => {
|
||||
const store = useChatStore()
|
||||
const session = makeSession()
|
||||
session.source = 'cli'
|
||||
store.sessions = [session]
|
||||
store.activeSessionId = 'session-1'
|
||||
store.activeSession = session
|
||||
|
||||
await store.sendMessage('/terminal pwd')
|
||||
|
||||
expect(chatApi.startRunViaSocket).toHaveBeenCalledTimes(1)
|
||||
expect(chatApi.startRunViaSocket.mock.calls[0][0]).toEqual(expect.objectContaining({
|
||||
input: '/terminal pwd',
|
||||
session_id: 'session-1',
|
||||
source: 'cli',
|
||||
}))
|
||||
expect(store.messages).toEqual([
|
||||
expect.objectContaining({
|
||||
role: 'user',
|
||||
content: '/terminal pwd',
|
||||
queued: false,
|
||||
systemType: undefined,
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('sends the selected workspace and reasoning effort when starting a bridge run', async () => {
|
||||
const store = useChatStore()
|
||||
const session = makeSession()
|
||||
session.source = 'cli'
|
||||
session.provider = 'openrouter'
|
||||
session.model = 'anthropic/claude-sonnet-4.6'
|
||||
session.workspace = '/workspace/project-a'
|
||||
session.reasoningEffort = 'high'
|
||||
store.sessions = [session]
|
||||
store.activeSessionId = 'session-1'
|
||||
store.activeSession = session
|
||||
|
||||
await store.sendMessage('use this project')
|
||||
|
||||
expect(chatApi.startRunViaSocket.mock.calls[0][0]).toEqual(expect.objectContaining({
|
||||
source: 'cli',
|
||||
workspace: '/workspace/project-a',
|
||||
reasoning_effort: 'high',
|
||||
}))
|
||||
})
|
||||
|
||||
it('sends only the bridge run fields on the run payload', async () => {
|
||||
const store = useChatStore()
|
||||
const session = makeSession()
|
||||
store.sessions = [session]
|
||||
store.activeSessionId = 'session-1'
|
||||
store.activeSession = session
|
||||
|
||||
await store.sendMessage('hello')
|
||||
|
||||
expect(Object.keys(chatApi.startRunViaSocket.mock.calls[0][0] as Record<string, unknown>).sort())
|
||||
.toEqual([
|
||||
'input',
|
||||
'model',
|
||||
'model_groups',
|
||||
'profile',
|
||||
'provider',
|
||||
'queue_id',
|
||||
'reasoning_effort',
|
||||
'session_id',
|
||||
'source',
|
||||
'workspace',
|
||||
])
|
||||
})
|
||||
|
||||
it('switches the session model without touching runtime credentials', async () => {
|
||||
const store = useChatStore()
|
||||
const session = makeSession()
|
||||
session.provider = 'xiaomi'
|
||||
session.model = 'mimo-v2.5-pro'
|
||||
store.sessions = [session]
|
||||
store.activeSessionId = 'session-1'
|
||||
store.activeSession = session
|
||||
|
||||
const ok = await store.switchSessionModel('deepseek-v4-pro', 'deepseek', 'session-1')
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(sessionsApi.setSessionModel).toHaveBeenCalledWith(
|
||||
'session-1',
|
||||
'deepseek-v4-pro',
|
||||
'deepseek',
|
||||
)
|
||||
expect(session.provider).toBe('deepseek')
|
||||
expect(session.model).toBe('deepseek-v4-pro')
|
||||
})
|
||||
|
||||
it('keeps a local stream controller for active runs', async () => {
|
||||
const abort = vi.fn()
|
||||
chatApi.startRunViaSocket.mockReturnValue({ abort })
|
||||
const store = useChatStore()
|
||||
const session = makeSession()
|
||||
store.sessions = [session]
|
||||
store.activeSessionId = 'session-1'
|
||||
store.activeSession = session
|
||||
|
||||
await store.sendMessage('long running task')
|
||||
const onEvent = chatApi.startRunViaSocket.mock.calls[0][1] as (event: RunEvent) => void
|
||||
onEvent({ event: 'run.started', session_id: 'session-1' })
|
||||
|
||||
store.stopStreaming()
|
||||
|
||||
expect(abort).toHaveBeenCalledTimes(1)
|
||||
expect(chatApi.socketEmit).not.toHaveBeenCalled()
|
||||
expect(store.abortState).toEqual(expect.objectContaining({ aborting: true }))
|
||||
})
|
||||
|
||||
it('keeps a session in the local list when server deletion fails', async () => {
|
||||
const store = useChatStore()
|
||||
const session = makeSession()
|
||||
store.sessions = [session]
|
||||
store.activeSessionId = 'session-1'
|
||||
store.activeSession = session
|
||||
sessionsApi.deleteSession.mockResolvedValue(false)
|
||||
|
||||
await expect(store.deleteSession('session-1')).resolves.toBe(false)
|
||||
|
||||
expect(store.sessions).toEqual([session])
|
||||
expect(store.activeSessionId).toBe('session-1')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user