Files
hermes-ui/tests/server/run-chat-queued-item.test.ts
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

463 lines
16 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
const handleBridgeRunMock = vi.hoisted(() => vi.fn(async () => {}))
const resumeBridgeRunMock = vi.hoisted(() => vi.fn(async () => {}))
const loadSessionStateFromDbMock = vi.hoisted(() => vi.fn())
const ensureReadyMock = vi.hoisted(() => vi.fn())
const sessionCommandMocks = vi.hoisted(() => ({
handleSessionCommand: vi.fn(),
isSessionCommand: vi.fn(() => false),
parseSessionCommand: vi.fn(() => null),
}))
const bridgeMock = vi.hoisted(() => ({
status: vi.fn(),
statusIfLoaded: vi.fn(),
interrupt: vi.fn(),
approvalRespond: vi.fn(),
}))
const sessionStoreMocks = vi.hoisted(() => ({
clearSessionMessages: vi.fn(),
}))
vi.mock('../../packages/server/src/services/hermes/run-chat/handle-bridge-run', () => ({
handleBridgeRun: handleBridgeRunMock,
resumeBridgeRun: resumeBridgeRunMock,
}))
vi.mock('../../packages/server/src/services/hermes/run-chat/load-state', () => ({
loadSessionStateFromDb: loadSessionStateFromDbMock,
resolveRunSource: vi.fn((source?: string) => source || 'cli'),
}))
vi.mock('../../packages/server/src/services/hermes/run-chat/session-command', () => sessionCommandMocks)
vi.mock('../../packages/server/src/services/hermes/agent-bridge', () => ({
AgentBridgeClient: vi.fn(() => bridgeMock),
}))
vi.mock('../../packages/server/src/services/hermes/agent-bridge/manager', () => ({
getAgentBridgeManager: vi.fn(() => ({
ensureReady: ensureReadyMock,
})),
}))
vi.mock('../../packages/server/src/services/logger', () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}))
vi.mock('../../packages/server/src/lib/llm-prompt', () => ({
getSystemPrompt: vi.fn(() => 'system prompt'),
}))
vi.mock('../../packages/server/src/db/hermes/session-store', () => ({
clearSessionMessages: sessionStoreMocks.clearSessionMessages,
getSession: vi.fn(() => ({ id: 'session-1', profile: 'default', source: 'cli' })),
getSessionDetail: vi.fn(() => null),
}))
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
getActiveProfileName: vi.fn(() => 'default'),
getProfileDir: vi.fn(() => '/tmp/hermes-default'),
listProfileNamesFromDisk: vi.fn(() => ['default']),
}))
vi.mock('../../packages/server/src/middleware/user-auth', () => ({
authenticateUserToken: vi.fn(),
isAuthEnabled: vi.fn(async () => false),
}))
vi.mock('../../packages/server/src/db/hermes/users-store', () => ({
userCanAccessProfile: vi.fn(() => true),
}))
function makeServerHarness() {
const handlers = new Map<string, Function>()
const sockets = new Map<string, any>()
const namespace = {
adapter: { rooms: new Map([['session:session-1', new Set(['socket-1'])]]) },
sockets,
emit: vi.fn(),
to: vi.fn(() => ({ emit: vi.fn() })),
use: vi.fn(),
on: vi.fn(),
}
const io = { of: vi.fn(() => namespace) }
const socket = {
id: 'socket-1',
connected: true,
handshake: { auth: {}, query: { profile: 'default' } },
data: {},
emit: vi.fn(),
join: vi.fn(),
to: vi.fn(() => ({ emit: vi.fn() })),
on: vi.fn((event: string, handler: Function) => {
handlers.set(event, handler)
}),
}
sockets.set(socket.id, socket)
return { handlers, io, namespace, socket }
}
describe('ChatRunSocket queued bridge runs', () => {
beforeEach(() => {
vi.clearAllMocks()
ensureReadyMock.mockResolvedValue({
reachable: true,
status: 'ready',
endpoint: 'ipc:///tmp/hermes-agent-bridge.sock',
})
bridgeMock.statusIfLoaded.mockResolvedValue({ ok: true, exists: false, running: false, loaded: false })
bridgeMock.interrupt.mockResolvedValue({ ok: true })
bridgeMock.approvalRespond.mockResolvedValue({ resolved: true })
sessionStoreMocks.clearSessionMessages.mockReturnValue(2)
loadSessionStateFromDbMock.mockResolvedValue({
messages: [],
isWorking: false,
isAborting: false,
events: [],
queue: [],
})
})
it('dispatches unknown slash bridge input through the normal bridge run path', async () => {
const { ChatRunSocket } = await import('../../packages/server/src/services/hermes/run-chat')
const { handlers, io, socket } = makeServerHarness()
const server = new ChatRunSocket(io as any)
;(server as any).onConnection(socket)
sessionCommandMocks.parseSessionCommand.mockReturnValueOnce(null)
sessionCommandMocks.isSessionCommand.mockReturnValueOnce(false)
await handlers.get('run')?.({
session_id: 'session-1',
input: '/terminal pwd',
source: 'cli',
queue_id: 'queue-terminal',
profile: 'default',
})
expect(sessionCommandMocks.parseSessionCommand).toHaveBeenCalledWith('/terminal pwd')
expect(sessionCommandMocks.handleSessionCommand).not.toHaveBeenCalled()
await vi.waitFor(() => expect(handleBridgeRunMock).toHaveBeenCalled())
const call = handleBridgeRunMock.mock.calls.at(-1)!
expect(call[2]).toEqual(expect.objectContaining({
input: '/terminal pwd',
source: 'cli',
queue_id: 'queue-terminal',
}))
expect(call[6]).toBe(false)
})
it('persists normal queued bridge messages when they are dequeued', async () => {
const { ChatRunSocket } = await import('../../packages/server/src/services/hermes/run-chat')
const { io, socket } = makeServerHarness()
const server = new ChatRunSocket(io as any)
;(server as any).runQueuedItem(socket, 'session-1', {
queue_id: 'queue-normal',
input: 'queued follow-up',
source: 'cli',
profile: 'default',
}, 'default')
await vi.waitFor(() => expect(handleBridgeRunMock).toHaveBeenCalled())
const call = handleBridgeRunMock.mock.calls.at(-1)!
expect(call[2]).toEqual(expect.objectContaining({
input: 'queued follow-up',
display_input: undefined,
storage_message: undefined,
queue_id: 'queue-normal',
}))
expect(call[6]).toBe(false)
})
it('supports bridge peer broadcasts during runAndWait workflow runs', async () => {
handleBridgeRunMock.mockImplementationOnce(async (_nsp, socket, data) => {
socket.to(`session:${data.session_id}`).emit('run.peer_user_message', {
event: 'run.peer_user_message',
session_id: data.session_id,
})
data.onEvent?.('run.completed', {
run_id: 'run-workflow-1',
output: 'done',
})
})
const { ChatRunSocket } = await import('../../packages/server/src/services/hermes/run-chat')
const { io, namespace } = makeServerHarness()
const server = new ChatRunSocket(io as any)
const result = await server.runAndWait({
session_id: 'session-1',
input: 'workflow node',
source: 'workflow',
session_source: 'workflow',
}, { profile: 'default' })
expect(result).toMatchObject({
ok: true,
run_id: 'run-workflow-1',
output: 'done',
})
expect(namespace.to).toHaveBeenCalledWith('session:session-1')
})
it('auto-responds once to approvals only when runAndWait enables it', async () => {
handleBridgeRunMock.mockImplementationOnce(async (_nsp, _socket, data) => {
data.onEvent?.('approval.requested', {
run_id: 'run-workflow-approval',
approval_id: 'approval-1',
choices: ['once', 'session', 'deny'],
})
data.onEvent?.('run.completed', {
run_id: 'run-workflow-approval',
output: 'approved',
})
})
const { ChatRunSocket } = await import('../../packages/server/src/services/hermes/run-chat')
const { io, namespace } = makeServerHarness()
const server = new ChatRunSocket(io as any)
const result = await server.runAndWait({
session_id: 'session-1',
input: 'workflow node',
source: 'workflow',
session_source: 'workflow',
}, { profile: 'default', approvalChoice: 'once' })
expect(result).toMatchObject({
ok: true,
run_id: 'run-workflow-approval',
output: 'approved',
})
expect(bridgeMock.approvalRespond).toHaveBeenCalledWith('approval-1', 'once')
expect(namespace.to).toHaveBeenCalledWith('session:session-1')
})
it('does not auto-respond to approvals for normal runAndWait calls', async () => {
handleBridgeRunMock.mockImplementationOnce(async (_nsp, _socket, data) => {
data.onEvent?.('approval.requested', {
run_id: 'run-normal-approval',
approval_id: 'approval-normal',
choices: ['once', 'session', 'deny'],
})
data.onEvent?.('run.completed', {
run_id: 'run-normal-approval',
output: 'manual approval path',
})
})
const { ChatRunSocket } = await import('../../packages/server/src/services/hermes/run-chat')
const { io } = makeServerHarness()
const server = new ChatRunSocket(io as any)
const result = await server.runAndWait({
session_id: 'session-1',
input: 'normal node',
source: 'cli',
}, { profile: 'default' })
expect(result.ok).toBe(true)
expect(bridgeMock.approvalRespond).not.toHaveBeenCalled()
})
it('persists the visible plan command when dequeuing expanded plan command runs', async () => {
const { ChatRunSocket } = await import('../../packages/server/src/services/hermes/run-chat')
const { io, socket } = makeServerHarness()
const server = new ChatRunSocket(io as any)
;(server as any).runQueuedItem(socket, 'session-1', {
queue_id: 'queue-plan',
input: '[IMPORTANT: expanded plan skill prompt]',
displayInput: '/plan build the feature',
displayRole: 'command',
storageMessage: '/plan build the feature',
source: 'cli',
profile: 'default',
}, 'default')
await vi.waitFor(() => expect(handleBridgeRunMock).toHaveBeenCalled())
const call = handleBridgeRunMock.mock.calls.at(-1)!
expect(call[2]).toEqual(expect.objectContaining({
input: '[IMPORTANT: expanded plan skill prompt]',
display_input: '/plan build the feature',
display_role: 'command',
storage_message: '/plan build the feature',
queue_id: 'queue-plan',
}))
expect(call[6]).toBe(false)
})
it('queues messages carrying a retired legacy source while a turn is active', async () => {
const { ChatRunSocket } = await import('../../packages/server/src/services/hermes/run-chat')
const { handlers, io, namespace, socket } = makeServerHarness()
const server = new ChatRunSocket(io as any)
;(server as any).onConnection(socket)
;(server as any).sessionMap.set('session-1', {
messages: [],
isWorking: true,
isAborting: false,
events: [],
queue: [],
source: 'retired_source',
})
await handlers.get('run')?.({
session_id: 'session-1',
input: 'queued follow-up',
source: 'retired_source',
queue_id: 'queue-legacy',
model: 'gpt-5-codex',
provider: 'openai-codex',
profile: 'default',
})
expect(handleBridgeRunMock).not.toHaveBeenCalled()
expect((server as any).sessionMap.get('session-1').queue).toEqual([
expect.objectContaining({
queue_id: 'queue-legacy',
input: 'queued follow-up',
source: 'retired_source',
}),
])
expect(namespace.to).toHaveBeenCalledWith('session:session-1')
})
it('dequeues messages carrying a retired legacy source on the bridge', async () => {
const { ChatRunSocket } = await import('../../packages/server/src/services/hermes/run-chat')
const { io, socket } = makeServerHarness()
const server = new ChatRunSocket(io as any)
;(server as any).sessionMap.set('session-1', {
messages: [],
isWorking: true,
isAborting: false,
events: [],
queue: [{
queue_id: 'queue-legacy',
input: 'queued follow-up',
source: 'retired_source',
model: 'gpt-5-codex',
provider: 'openai-codex',
profile: 'default',
originSocketId: socket.id,
}],
source: 'retired_source',
})
;(server as any).markExternalRunCompleted('session-1', 'run.completed')
await vi.waitFor(() => expect(handleBridgeRunMock).toHaveBeenCalled())
const call = handleBridgeRunMock.mock.calls.at(-1)!
expect(call[2]).toEqual(expect.objectContaining({
input: 'queued follow-up',
source: 'retired_source',
queue_id: 'queue-legacy',
}))
expect((server as any).sessionMap.get('session-1').queue).toEqual([])
})
it('checks bridge resume status without cold-starting the profile worker', async () => {
const { ChatRunSocket } = await import('../../packages/server/src/services/hermes/run-chat')
const { handlers, io, socket } = makeServerHarness()
const server = new ChatRunSocket(io as any)
;(server as any).onConnection(socket)
await handlers.get('resume')?.({ session_id: 'session-1' })
expect(bridgeMock.statusIfLoaded).toHaveBeenCalledWith('session-1', 'default', { timeoutMs: 1000 })
expect(bridgeMock.status).not.toHaveBeenCalled()
expect(resumeBridgeRunMock).not.toHaveBeenCalled()
expect(socket.emit).toHaveBeenCalledWith('resumed', expect.objectContaining({
session_id: 'session-1',
isWorking: false,
}))
})
it('reattaches a loaded running bridge run during resume', async () => {
bridgeMock.statusIfLoaded.mockResolvedValueOnce({
ok: true,
exists: true,
running: true,
current_run_id: 'run-1',
loaded: true,
})
const { ChatRunSocket } = await import('../../packages/server/src/services/hermes/run-chat')
const { handlers, io, socket } = makeServerHarness()
const server = new ChatRunSocket(io as any)
;(server as any).onConnection(socket)
await handlers.get('resume')?.({ session_id: 'session-1' })
expect(resumeBridgeRunMock).toHaveBeenCalledWith(
expect.anything(),
socket,
expect.objectContaining({
sessionId: 'session-1',
runId: 'run-1',
profile: 'default',
}),
expect.any(Map),
bridgeMock,
expect.any(Function),
)
})
it('clears chat-run memory state when an external MCU clear removes history', async () => {
const { ChatRunSocket } = await import('../../packages/server/src/services/hermes/run-chat')
const { io, namespace } = makeServerHarness()
const server = new ChatRunSocket(io as any)
const abortController = new AbortController()
;(server as any).sessionMap.set('session-1', {
messages: [
{ id: 1, session_id: 'session-1', role: 'user', content: 'old', timestamp: 1 },
],
messageTotal: 1,
messageLoadedCount: 1,
messagePageLimit: 50,
hasMoreBefore: false,
isWorking: true,
isAborting: false,
events: [{ event: 'message.delta', data: { session_id: 'session-1', delta: 'old' } }],
queue: [{
queue_id: 'q1',
input: 'next',
profile: 'default',
}],
abortController,
runId: 'run-1',
activeRunMarker: 'marker-1',
profile: 'default',
source: 'global_agent',
inputTokens: 10,
outputTokens: 5,
contextTokens: 15,
bridgePendingAssistantContent: 'old',
bridgeOutput: 'old',
})
const abortSpy = vi.spyOn(abortController, 'abort')
const result = server.clearSessionHistory('session-1')
expect(result).toEqual({ deleted: 2, hadMemoryState: true })
expect(sessionStoreMocks.clearSessionMessages).toHaveBeenCalledWith('session-1')
expect(abortSpy).toHaveBeenCalled()
expect(bridgeMock.interrupt).toHaveBeenCalledWith('session-1', 'Session cleared', 'default')
expect((server as any).sessionMap.has('session-1')).toBe(false)
expect(namespace.emit).toHaveBeenCalledWith('session.command', expect.objectContaining({
event: 'session.command',
session_id: 'session-1',
action: 'clear',
clearHistory: true,
deleted: 2,
}))
expect(namespace.emit).toHaveBeenCalledWith('resumed', expect.objectContaining({
session_id: 'session-1',
messages: [],
messageTotal: 0,
isWorking: false,
queueLength: 0,
}))
})
})