Files
hermes-web-ui/tests/server/session-command-branch.test.ts
牧濑红莉栖(BOT) 0bfa9d2ed4 fix(coding-agent): 持久化会话 API 模式 (#1944)
* fix(coding-agent): persist API mode per session

* docs(chat-chain): document coding agent API mode persistence

---------

Co-authored-by: x1051445024 <你的GitHub注册邮箱>
2026-07-06 08:17:49 +08:00

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,
}))
})
})