Files
hermes-ui/tests/client/chat-store-reasoning-tool-boundary.test.ts
T
toor 8c1ddf415f Phase 003: remove the coding-agent and ekko-agent runtimes
Every chat session now runs through the Hermes bridge, so the three chat
chains become feature-equivalent instead of three partially-maintained
dispatch paths. Keeping the coding-agent and in-house agent runtimes alive
meant every bridge change had to be mirrored into two more runtimes that
had already drifted.

Historical rows are read, not migrated: sessions stored with the retired
source normalize to cli on load, workflow graphs naming the retired agents
keep loading and run on the bridge, and the visible-source check plus
SOURCE_LABELS still recognize the old value so those sessions stay listed.

The public surface follows the code: the OpenAPI generator and the MCP
chat-run tool schema drop the six retired entry fields so external callers
are not told about runtimes that no longer exist.

harness-check gains a RETIRED_RUNTIME scan so the residual check is a
mechanical gate instead of a manual grep, with the two surviving read-side
compat sites recorded with reasons.

Refs: docs/phases/phase_003.md
2026-10-16 13:27:50 +08:00

541 lines
17 KiB
TypeScript

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