mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-07-11 19:20:16 +00:00
0bfa9d2ed4
* fix(coding-agent): persist API mode per session * docs(chat-chain): document coding agent API mode persistence --------- Co-authored-by: x1051445024 <你的GitHub注册邮箱>
292 lines
12 KiB
TypeScript
292 lines
12 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const addMessageMock = vi.fn(() => 99)
|
|
const addMessagesMock = vi.fn()
|
|
const clearSessionMessagesMock = vi.fn()
|
|
const createBranchedSessionMock = vi.fn((row: any) => row)
|
|
const createSessionMock = vi.fn()
|
|
const getSessionMock = vi.fn()
|
|
const getSessionDetailMock = vi.fn()
|
|
const renameSessionMock = vi.fn()
|
|
const updateSessionMock = vi.fn()
|
|
const updateSessionStatsMock = vi.fn()
|
|
const getOrCreateSessionMock = vi.fn((sessionMap: Map<string, any>, sessionId: string) => {
|
|
if (!sessionMap.has(sessionId)) {
|
|
sessionMap.set(sessionId, { messages: [], isWorking: false, events: [], queue: [] })
|
|
}
|
|
return sessionMap.get(sessionId)
|
|
})
|
|
|
|
vi.mock('../../packages/server/src/db/hermes/session-store', () => ({
|
|
addMessage: addMessageMock,
|
|
addMessages: addMessagesMock,
|
|
clearSessionMessages: clearSessionMessagesMock,
|
|
createBranchedSession: createBranchedSessionMock,
|
|
createSession: createSessionMock,
|
|
getSession: getSessionMock,
|
|
getSessionDetail: getSessionDetailMock,
|
|
renameSession: renameSessionMock,
|
|
updateSession: updateSessionMock,
|
|
updateSessionStats: updateSessionStatsMock,
|
|
}))
|
|
|
|
vi.mock('../../packages/server/src/services/hermes/run-chat/compression', () => ({
|
|
buildDbHistory: vi.fn(),
|
|
estimateSnapshotAwareHistoryUsage: vi.fn(),
|
|
forceCompressBridgeHistory: vi.fn(),
|
|
getOrCreateSession: getOrCreateSessionMock,
|
|
replaceState: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('../../packages/server/src/services/hermes/run-chat/usage', () => ({
|
|
calcAndUpdateUsage: vi.fn(async () => ({ inputTokens: 0, outputTokens: 0 })),
|
|
contextTokensWithCachedOverhead: vi.fn((_state: any, tokens: number) => tokens),
|
|
updateMessageContextTokenUsage: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('../../packages/server/src/services/hermes/run-chat/abort', () => ({
|
|
handleAbort: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('../../packages/server/src/services/hermes/run-chat/bridge-message', () => ({
|
|
flushBridgePendingToDb: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('../../packages/server/src/services/logger', () => ({
|
|
logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
|
}))
|
|
|
|
function makeSocketHarness() {
|
|
const namespaceEmit = vi.fn()
|
|
const socketEmit = vi.fn()
|
|
const nsp = {
|
|
to: vi.fn(() => ({ emit: namespaceEmit })),
|
|
adapter: { rooms: new Map([['session:session-1', new Set(['socket-1'])]]) },
|
|
}
|
|
const socket = {
|
|
id: 'socket-1',
|
|
join: vi.fn(),
|
|
emit: socketEmit,
|
|
connected: true,
|
|
}
|
|
return { nsp, socket, namespaceEmit, socketEmit }
|
|
}
|
|
|
|
function makeParentSession(overrides: Record<string, any> = {}) {
|
|
return {
|
|
id: 'session-1',
|
|
profile: 'default',
|
|
source: 'cli',
|
|
agent: 'hermes',
|
|
agent_mode: '',
|
|
agent_session_id: '',
|
|
agent_native_session_id: '',
|
|
model: 'openai/gpt-5.4',
|
|
provider: 'openai-codex',
|
|
api_mode: 'chat_completions',
|
|
title: 'Parent chat',
|
|
preview: 'Parent prompt',
|
|
workspace: '/repo',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function makeCtx(sessionMap: Map<string, any>, nsp: any, socket: any, bridgeOverrides: Record<string, any> = {}) {
|
|
return {
|
|
nsp,
|
|
socket,
|
|
sessionMap,
|
|
bridge: {
|
|
status: vi.fn(async () => ({ exists: true, running: false, currentRunId: null })),
|
|
...bridgeOverrides,
|
|
} as any,
|
|
profile: 'default',
|
|
model: 'openai/gpt-5.4',
|
|
provider: 'openai-codex',
|
|
runQueuedItem: vi.fn(),
|
|
}
|
|
}
|
|
|
|
describe('branch session command', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
createBranchedSessionMock.mockImplementation((row: any) => row)
|
|
createSessionMock.mockImplementation((row: any) => row)
|
|
getSessionMock.mockReturnValue(makeParentSession())
|
|
getSessionDetailMock.mockReturnValue({
|
|
messages: [
|
|
{ id: 1, session_id: 'session-1', role: 'user', content: 'Root prompt', display_role: null, display_content: null, timestamp: 101, tool_call_id: null, tool_calls: null, tool_name: null, token_count: null, finish_reason: null, reasoning: null, reasoning_details: null, reasoning_content: null },
|
|
{ id: 2, session_id: 'session-1', role: 'assistant', content: 'Root answer', display_role: null, display_content: null, timestamp: 102, tool_call_id: null, tool_calls: null, tool_name: null, token_count: null, finish_reason: null, reasoning: null, reasoning_details: null, reasoning_content: null },
|
|
],
|
|
})
|
|
})
|
|
|
|
it('parses /fork as the only user-facing fork command', async () => {
|
|
const { parseSessionCommand } = await import('../../packages/server/src/services/hermes/run-chat/session-command')
|
|
|
|
expect(parseSessionCommand('/fork')).toMatchObject({ name: 'branch', rawName: 'fork', args: '' })
|
|
expect(parseSessionCommand('/branch alternate path')).toBeNull()
|
|
})
|
|
|
|
it('rejects /fork while the bridge session is running', async () => {
|
|
const { handleSessionCommand, parseSessionCommand } = await import('../../packages/server/src/services/hermes/run-chat/session-command')
|
|
const { nsp, socket, namespaceEmit } = makeSocketHarness()
|
|
const sessionMap = new Map<string, any>([
|
|
['session-1', { messages: [], isWorking: true, events: [], queue: [] }],
|
|
])
|
|
|
|
await handleSessionCommand('session-1', parseSessionCommand('/fork side path')!, makeCtx(sessionMap, nsp, socket))
|
|
|
|
expect(createBranchedSessionMock).not.toHaveBeenCalled()
|
|
expect(createSessionMock).not.toHaveBeenCalled()
|
|
expect(addMessagesMock).not.toHaveBeenCalled()
|
|
expect(updateSessionMock).not.toHaveBeenCalled()
|
|
expect(namespaceEmit).toHaveBeenCalledWith('session.command', expect.objectContaining({
|
|
action: 'branch',
|
|
ok: false,
|
|
terminal: false,
|
|
message: expect.stringContaining('Cannot branch while the session is running'),
|
|
}))
|
|
})
|
|
|
|
it('rejects /fork for coding agent sessions', async () => {
|
|
const { handleSessionCommand, parseSessionCommand } = await import('../../packages/server/src/services/hermes/run-chat/session-command')
|
|
const { nsp, socket, namespaceEmit } = makeSocketHarness()
|
|
const sessionMap = new Map<string, any>([
|
|
['session-1', { messages: [], isWorking: false, events: [], queue: [] }],
|
|
])
|
|
getSessionMock.mockReturnValue(makeParentSession({ source: 'coding_agent', agent: 'codex' }))
|
|
|
|
await handleSessionCommand('session-1', parseSessionCommand('/fork side path')!, makeCtx(sessionMap, nsp, socket))
|
|
|
|
expect(createBranchedSessionMock).not.toHaveBeenCalled()
|
|
expect(namespaceEmit).toHaveBeenCalledWith('session.command', expect.objectContaining({
|
|
action: 'branch',
|
|
ok: false,
|
|
terminal: true,
|
|
message: expect.stringContaining('Cannot branch coding agent sessions'),
|
|
}))
|
|
})
|
|
|
|
it('rejects /fork when there are no visible conversation messages', async () => {
|
|
const { handleSessionCommand, parseSessionCommand } = await import('../../packages/server/src/services/hermes/run-chat/session-command')
|
|
const { nsp, socket, namespaceEmit } = makeSocketHarness()
|
|
const sessionMap = new Map<string, any>([
|
|
['session-1', { messages: [], isWorking: false, events: [], queue: [] }],
|
|
])
|
|
getSessionDetailMock.mockReturnValueOnce({
|
|
messages: [
|
|
{ id: 1, session_id: 'session-1', role: 'command', content: '/status', display_role: null, display_content: null, timestamp: 101, tool_call_id: null, tool_calls: null, tool_name: null, token_count: null, finish_reason: null, reasoning: null, reasoning_details: null, reasoning_content: null },
|
|
],
|
|
})
|
|
|
|
await handleSessionCommand('session-1', parseSessionCommand('/fork empty')!, makeCtx(sessionMap, nsp, socket))
|
|
|
|
expect(createBranchedSessionMock).not.toHaveBeenCalled()
|
|
expect(namespaceEmit).toHaveBeenCalledWith('session.command', expect.objectContaining({
|
|
action: 'branch',
|
|
ok: false,
|
|
terminal: true,
|
|
message: expect.stringContaining('no conversation messages'),
|
|
}))
|
|
})
|
|
|
|
it('auto-titles /fork as branch: original title by default', async () => {
|
|
const { handleSessionCommand, parseSessionCommand } = await import('../../packages/server/src/services/hermes/run-chat/session-command')
|
|
const { nsp, socket, namespaceEmit } = makeSocketHarness()
|
|
const sessionMap = new Map<string, any>([
|
|
['session-1', { messages: [], isWorking: false, events: [], queue: [] }],
|
|
])
|
|
|
|
await handleSessionCommand('session-1', parseSessionCommand('/fork')!, makeCtx(sessionMap, nsp, socket))
|
|
|
|
expect(createBranchedSessionMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
title: 'branch: Parent chat',
|
|
parent_session_id: 'session-1',
|
|
}))
|
|
const branchEvent = namespaceEmit.mock.calls.find(call => call[0] === 'session.command')?.[1]
|
|
expect(branchEvent).toMatchObject({
|
|
newSessionTitle: 'branch: Parent chat',
|
|
branchSession: expect.objectContaining({ title: 'branch: Parent chat' }),
|
|
})
|
|
})
|
|
|
|
it('forks an idle local bridge chat by copying persisted messages into a child session', async () => {
|
|
const { handleSessionCommand, parseSessionCommand } = await import('../../packages/server/src/services/hermes/run-chat/session-command')
|
|
const { nsp, socket, namespaceEmit } = makeSocketHarness()
|
|
const sessionMap = new Map<string, any>([
|
|
['session-1', { messages: [], isWorking: false, events: [], queue: [] }],
|
|
])
|
|
|
|
await handleSessionCommand('session-1', parseSessionCommand('/fork Alternate')!, makeCtx(sessionMap, nsp, socket))
|
|
|
|
expect(updateSessionMock).not.toHaveBeenCalled()
|
|
expect(createSessionMock).not.toHaveBeenCalled()
|
|
expect(createBranchedSessionMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
title: 'Alternate',
|
|
parent_session_id: 'session-1',
|
|
profile: 'default',
|
|
source: 'cli',
|
|
agent: 'hermes',
|
|
model: 'openai/gpt-5.4',
|
|
provider: 'openai-codex',
|
|
api_mode: 'chat_completions',
|
|
workspace: '/repo',
|
|
ended_at: expect.any(Number),
|
|
last_active: expect.any(Number),
|
|
messages: [
|
|
expect.objectContaining({ role: 'user', content: 'Root prompt' }),
|
|
expect.objectContaining({ role: 'assistant', content: 'Root answer' }),
|
|
],
|
|
}))
|
|
expect(addMessagesMock).not.toHaveBeenCalled()
|
|
expect(addMessageMock).toHaveBeenCalledTimes(1)
|
|
expect(addMessageMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
session_id: 'session-1',
|
|
role: 'command',
|
|
content: expect.stringContaining('Branched session "Alternate"'),
|
|
}))
|
|
const branchEvent = namespaceEmit.mock.calls.find(call => call[0] === 'session.command')?.[1]
|
|
expect(branchEvent).toMatchObject({
|
|
action: 'branch',
|
|
ok: true,
|
|
parentSessionId: 'session-1',
|
|
newSessionTitle: 'Alternate',
|
|
newSessionId: expect.stringMatching(/^\d{8}_\d{6}_[0-9a-f]{6}$/),
|
|
branchSession: expect.objectContaining({
|
|
title: 'Alternate',
|
|
parentSessionId: 'session-1',
|
|
forkPointMessageId: null,
|
|
parentTitle: 'Parent chat',
|
|
parentLastMessage: 'Root answer',
|
|
parentLastMessageRole: 'assistant',
|
|
messageCount: 2,
|
|
}),
|
|
})
|
|
expect(sessionMap.has(branchEvent.newSessionId)).toBe(false)
|
|
})
|
|
|
|
it('preserves api_server source when forking non-bridge chat sessions', async () => {
|
|
const { handleSessionCommand, parseSessionCommand } = await import('../../packages/server/src/services/hermes/run-chat/session-command')
|
|
const { nsp, socket, namespaceEmit } = makeSocketHarness()
|
|
const sessionMap = new Map<string, any>([
|
|
['session-1', { messages: [], isWorking: false, events: [], queue: [] }],
|
|
])
|
|
getSessionMock.mockReturnValue(makeParentSession({ source: 'api_server', agent: 'hermes' }))
|
|
|
|
await handleSessionCommand('session-1', parseSessionCommand('/fork')!, makeCtx(sessionMap, nsp, socket))
|
|
|
|
expect(createBranchedSessionMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
source: 'api_server',
|
|
agent: 'hermes',
|
|
parent_session_id: 'session-1',
|
|
}))
|
|
const branchEvent = namespaceEmit.mock.calls.find(call => call[0] === 'session.command')?.[1]
|
|
expect(branchEvent.branchSession).toEqual(expect.objectContaining({
|
|
source: 'api_server',
|
|
parentSessionId: 'session-1',
|
|
forkPointMessageId: null,
|
|
}))
|
|
})
|
|
})
|