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
541 lines
17 KiB
TypeScript
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')
|
|
})
|
|
})
|