// @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).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') }) })