Import the client, server, skills, tests, and build scripts as the starting point for the rebuild, then trim the surfaces this project does not ship: Electron desktop packaging, the marketing website, and the ESP32 firmware tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows. Rename the package and CLI entry points to hermes-ui, drop the prepare hook so npm ci no longer triggers a build, and drop the removed tsconfig and nodemon references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs so the documented package map and hard rules match this repository. reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
497 lines
15 KiB
TypeScript
497 lines
15 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(),
|
|
registerSessionHandlers: vi.fn(),
|
|
unregisterSessionHandlers: vi.fn(),
|
|
getChatRunSocket: vi.fn(() => ({ emit: vi.fn() })),
|
|
resumeSession: vi.fn((sessionId: string, onResumed: (data: any) => void) => {
|
|
onResumed({ session_id: sessionId, messages: [], isWorking: false, events: [], queueLength: 0 })
|
|
return {} as any
|
|
}),
|
|
sessionCommandHandlers: [] as Array<(event: any) => void>,
|
|
peerUserMessageHandlers: [] as Array<(event: any) => void>,
|
|
sessionTitleUpdatedHandlers: [] as Array<(event: any) => void>,
|
|
sessionWorkspaceUpdatedHandlers: [] as Array<(event: any) => void>,
|
|
}))
|
|
|
|
vi.mock('@/api/hermes/chat', () => ({
|
|
startRunViaSocket: chatApi.startRunViaSocket,
|
|
resumeSession: chatApi.resumeSession,
|
|
registerSessionHandlers: chatApi.registerSessionHandlers,
|
|
unregisterSessionHandlers: chatApi.unregisterSessionHandlers,
|
|
getChatRunSocket: chatApi.getChatRunSocket,
|
|
respondToolApproval: vi.fn(),
|
|
respondClarify: vi.fn(),
|
|
onPeerUserMessage: vi.fn((handler: (event: any) => void) => {
|
|
chatApi.peerUserMessageHandlers.push(handler)
|
|
return vi.fn()
|
|
}),
|
|
onSessionCommand: vi.fn((handler: (event: any) => void) => {
|
|
chatApi.sessionCommandHandlers.push(handler)
|
|
return vi.fn()
|
|
}),
|
|
onSessionTitleUpdated: vi.fn((handler: (event: any) => void) => {
|
|
chatApi.sessionTitleUpdatedHandlers.push(handler)
|
|
return vi.fn()
|
|
}),
|
|
onSessionWorkspaceUpdated: vi.fn((handler: (event: any) => void) => {
|
|
chatApi.sessionWorkspaceUpdatedHandlers.push(handler)
|
|
return vi.fn()
|
|
}),
|
|
}))
|
|
|
|
vi.mock('@/api/client', () => ({
|
|
getActiveProfileName: () => 'default',
|
|
hasApiKey: () => false,
|
|
}))
|
|
|
|
vi.mock('@/api/hermes/sessions', () => ({
|
|
archiveSession: vi.fn(),
|
|
deleteSession: vi.fn(),
|
|
fetchSession: vi.fn(),
|
|
fetchSessions: vi.fn(),
|
|
fetchWorkspaceRunChangesForSession: vi.fn(async () => []),
|
|
fetchWorkspaceRunChangeFile: vi.fn(async () => null),
|
|
setSessionModel: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@/api/hermes/download', () => ({
|
|
getDownloadUrl: (_path: string, name: string) => `/download/${name}`,
|
|
}))
|
|
|
|
vi.mock('@/utils/completion-sound', () => ({
|
|
primeCompletionSound: vi.fn(),
|
|
playCompletionSound: vi.fn(),
|
|
}))
|
|
|
|
import { useChatStore, type Session } from '@/stores/hermes/chat'
|
|
|
|
function makeSession(): Session {
|
|
return {
|
|
id: 'session-1',
|
|
title: 'session',
|
|
messages: [],
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
}
|
|
}
|
|
|
|
describe('chat store session.command fanout', () => {
|
|
beforeEach(() => {
|
|
vi.resetAllMocks()
|
|
chatApi.sessionCommandHandlers = []
|
|
chatApi.peerUserMessageHandlers = []
|
|
chatApi.sessionTitleUpdatedHandlers = []
|
|
chatApi.startRunViaSocket.mockReturnValue({ abort: vi.fn() })
|
|
setActivePinia(createPinia())
|
|
})
|
|
|
|
it('attaches to a goal resume run started from another window', () => {
|
|
const store = useChatStore()
|
|
const session = makeSession()
|
|
store.sessions = [session]
|
|
store.activeSessionId = 'session-1'
|
|
store.activeSession = session
|
|
|
|
expect(chatApi.sessionCommandHandlers).toHaveLength(1)
|
|
|
|
chatApi.sessionCommandHandlers[0]({
|
|
event: 'session.command',
|
|
session_id: 'session-1',
|
|
command: 'goal',
|
|
action: 'resume',
|
|
message: 'Goal resumed',
|
|
started: true,
|
|
terminal: false,
|
|
})
|
|
|
|
expect(store.isStreaming).toBe(true)
|
|
expect(chatApi.registerSessionHandlers).toHaveBeenCalledWith('session-1', expect.objectContaining({
|
|
onRunStarted: expect.any(Function),
|
|
onSessionCommand: expect.any(Function),
|
|
}))
|
|
expect(store.messages).toEqual([
|
|
expect.objectContaining({
|
|
role: 'command',
|
|
content: 'Goal resumed',
|
|
commandAction: 'resume',
|
|
}),
|
|
])
|
|
})
|
|
|
|
it('does not clear the transcript for goal done commands', () => {
|
|
const store = useChatStore()
|
|
const session = makeSession()
|
|
session.messages = [
|
|
{ id: 'user-1', role: 'user', content: 'keep me', timestamp: 1 },
|
|
]
|
|
store.sessions = [session]
|
|
store.activeSessionId = 'session-1'
|
|
store.activeSession = session
|
|
|
|
chatApi.sessionCommandHandlers[0]({
|
|
event: 'session.command',
|
|
session_id: 'session-1',
|
|
command: 'goal',
|
|
action: 'clear',
|
|
message: 'Goal cleared.',
|
|
terminal: true,
|
|
})
|
|
|
|
expect(store.messages).toEqual([
|
|
expect.objectContaining({ id: 'user-1', content: 'keep me' }),
|
|
expect.objectContaining({
|
|
role: 'command',
|
|
content: 'Goal cleared.',
|
|
commandAction: 'clear',
|
|
}),
|
|
])
|
|
})
|
|
|
|
it('updates session title from the global generated-title event', () => {
|
|
const store = useChatStore()
|
|
const session = makeSession()
|
|
store.sessions = [session]
|
|
store.activeSessionId = 'session-1'
|
|
store.activeSession = session
|
|
|
|
expect(chatApi.sessionTitleUpdatedHandlers).toHaveLength(1)
|
|
|
|
chatApi.sessionTitleUpdatedHandlers[0]({
|
|
event: 'session.title.updated',
|
|
session_id: 'session-1',
|
|
title: 'Generated Title',
|
|
})
|
|
|
|
expect(store.sessions[0].title).toBe('Generated Title')
|
|
expect(store.activeSession?.title).toBe('Generated Title')
|
|
})
|
|
|
|
it('does not show a thinking/streaming state while submitting terminal fork commands', async () => {
|
|
const store = useChatStore()
|
|
const session = makeSession()
|
|
session.source = 'cli'
|
|
session.messageCount = 2
|
|
session.messages = [
|
|
{ id: 'user-1', role: 'user', content: 'Previous question', timestamp: 1 },
|
|
{ id: 'assistant-1', role: 'assistant', content: 'Previous answer', timestamp: 2 },
|
|
]
|
|
store.sessions = [session]
|
|
store.activeSessionId = 'session-1'
|
|
store.activeSession = session
|
|
|
|
await store.sendMessage('/fork')
|
|
|
|
expect(chatApi.startRunViaSocket).toHaveBeenCalledWith(
|
|
expect.objectContaining({ input: '/fork', session_id: 'session-1', source: 'cli' }),
|
|
expect.any(Function),
|
|
expect.any(Function),
|
|
expect.any(Function),
|
|
undefined,
|
|
expect.any(Object),
|
|
)
|
|
expect(store.isStreaming).toBe(false)
|
|
})
|
|
|
|
it('debounces terminal fork commands until the session.command settles', async () => {
|
|
const store = useChatStore()
|
|
const session = makeSession()
|
|
session.source = 'cli'
|
|
session.messageCount = 2
|
|
session.messages = [
|
|
{ id: 'user-1', role: 'user', content: 'Previous question', timestamp: 1 },
|
|
{ id: 'assistant-1', role: 'assistant', content: 'Previous answer', timestamp: 2 },
|
|
]
|
|
store.sessions = [session]
|
|
store.activeSessionId = 'session-1'
|
|
store.activeSession = session
|
|
|
|
await store.sendMessage('/fork')
|
|
await store.sendMessage('/fork')
|
|
|
|
expect(chatApi.startRunViaSocket).toHaveBeenCalledTimes(1)
|
|
expect(store.isStreaming).toBe(false)
|
|
expect(store.isForkPending).toBe(true)
|
|
|
|
chatApi.sessionCommandHandlers[0]({
|
|
event: 'session.command',
|
|
session_id: 'session-1',
|
|
command: 'fork',
|
|
action: 'branch',
|
|
ok: false,
|
|
message: 'Cannot branch: no conversation messages found to copy.',
|
|
terminal: true,
|
|
})
|
|
|
|
expect(store.isForkPending).toBe(false)
|
|
})
|
|
|
|
it('clears stale working state when terminal session commands complete', () => {
|
|
const store = useChatStore()
|
|
const session = makeSession()
|
|
store.sessions = [session]
|
|
store.activeSessionId = 'session-1'
|
|
store.activeSession = session
|
|
|
|
chatApi.sessionCommandHandlers[0]({
|
|
event: 'session.command',
|
|
session_id: 'session-1',
|
|
command: 'goal',
|
|
action: 'resume',
|
|
message: 'Goal resumed',
|
|
started: true,
|
|
terminal: false,
|
|
})
|
|
expect(store.isStreaming).toBe(true)
|
|
|
|
chatApi.sessionCommandHandlers[0]({
|
|
event: 'session.command',
|
|
session_id: 'session-1',
|
|
command: 'goal',
|
|
action: 'done',
|
|
message: 'Goal done.',
|
|
terminal: true,
|
|
})
|
|
|
|
expect(store.isStreaming).toBe(false)
|
|
})
|
|
|
|
it('settles stale runtime tool rows when terminal session commands complete', () => {
|
|
const store = useChatStore()
|
|
const session = makeSession()
|
|
session.messages = [
|
|
{ id: 'tool-1', role: 'tool', content: '', timestamp: 1, toolName: 'shell', toolStatus: 'running' },
|
|
]
|
|
store.sessions = [session]
|
|
store.activeSessionId = 'session-1'
|
|
store.activeSession = session
|
|
|
|
chatApi.sessionCommandHandlers[0]({
|
|
event: 'session.command',
|
|
session_id: 'session-1',
|
|
command: 'status',
|
|
action: 'status',
|
|
message: 'Status: idle',
|
|
terminal: true,
|
|
})
|
|
|
|
expect(store.messages[0]).toEqual(expect.objectContaining({
|
|
role: 'tool',
|
|
toolName: 'shell',
|
|
toolStatus: 'done',
|
|
}))
|
|
expect(store.isStreaming).toBe(false)
|
|
})
|
|
|
|
it('settles stale runtime tool rows before sending an idle slash command', async () => {
|
|
const store = useChatStore()
|
|
const session = makeSession()
|
|
session.source = 'cli'
|
|
session.messages = [
|
|
{ id: 'tool-1', role: 'tool', content: '', timestamp: 1, toolName: 'weather', toolStatus: 'running' },
|
|
]
|
|
store.sessions = [session]
|
|
store.activeSessionId = 'session-1'
|
|
store.activeSession = session
|
|
|
|
await store.sendMessage('/status')
|
|
|
|
expect(store.messages[0]).toEqual(expect.objectContaining({
|
|
role: 'tool',
|
|
toolName: 'weather',
|
|
toolStatus: 'done',
|
|
}))
|
|
expect(store.messages[1]).toEqual(expect.objectContaining({
|
|
role: 'command',
|
|
content: '/status',
|
|
}))
|
|
})
|
|
|
|
it('adds peer command messages to the transcript even after the session command marks the run live', () => {
|
|
const store = useChatStore()
|
|
const session = makeSession()
|
|
session.source = 'cli'
|
|
store.sessions = [session]
|
|
store.activeSessionId = 'session-1'
|
|
store.activeSession = session
|
|
|
|
chatApi.sessionCommandHandlers.forEach(handler => handler({
|
|
event: 'session.command',
|
|
session_id: 'session-1',
|
|
command: 'moa',
|
|
action: 'moa',
|
|
message: 'MoA one-shot queued with preset default.',
|
|
started: true,
|
|
terminal: false,
|
|
}))
|
|
chatApi.peerUserMessageHandlers.forEach(handler => handler({
|
|
event: 'run.peer_user_message',
|
|
session_id: 'session-1',
|
|
message: {
|
|
id: 'queue-moa',
|
|
role: 'command',
|
|
content: '/moa test',
|
|
timestamp: 2,
|
|
},
|
|
}))
|
|
|
|
expect(store.queuedUserMessages.get('session-1')).toBeUndefined()
|
|
expect(store.messages).toEqual([
|
|
expect.objectContaining({
|
|
role: 'command',
|
|
content: 'MoA one-shot queued with preset default.',
|
|
commandAction: 'moa',
|
|
}),
|
|
expect.objectContaining({
|
|
id: 'queue-moa',
|
|
role: 'command',
|
|
content: '/moa test',
|
|
queued: false,
|
|
}),
|
|
])
|
|
})
|
|
|
|
it('moves an existing peer command queue entry into the transcript when the command starts', () => {
|
|
const store = useChatStore()
|
|
const session = makeSession()
|
|
session.source = 'cli'
|
|
store.sessions = [session]
|
|
store.activeSessionId = 'session-1'
|
|
store.activeSession = session
|
|
|
|
chatApi.sessionCommandHandlers.forEach(handler => handler({
|
|
event: 'session.command',
|
|
session_id: 'session-1',
|
|
action: 'moa',
|
|
started: true,
|
|
terminal: false,
|
|
}))
|
|
chatApi.registerSessionHandlers.mock.calls.at(-1)?.[1]?.onRunQueued?.({
|
|
event: 'run.queued',
|
|
session_id: 'session-1',
|
|
queue_length: 1,
|
|
queued_messages: [
|
|
{ id: 'queue-moa', role: 'command', content: '/moa test', timestamp: 2, queued: true },
|
|
],
|
|
})
|
|
|
|
chatApi.peerUserMessageHandlers.forEach(handler => handler({
|
|
event: 'run.peer_user_message',
|
|
session_id: 'session-1',
|
|
message: {
|
|
id: 'queue-moa',
|
|
role: 'command',
|
|
content: '/moa test',
|
|
timestamp: 3,
|
|
},
|
|
}))
|
|
|
|
expect(store.queuedUserMessages.get('session-1')).toBeUndefined()
|
|
expect(store.messages).toEqual([
|
|
expect.objectContaining({
|
|
id: 'queue-moa',
|
|
role: 'command',
|
|
content: '/moa test',
|
|
queued: false,
|
|
}),
|
|
])
|
|
})
|
|
|
|
it('adds and switches to a branched child session from session.command branch events', async () => {
|
|
const store = useChatStore()
|
|
const session = makeSession()
|
|
store.sessions = [session]
|
|
store.activeSessionId = 'session-1'
|
|
store.activeSession = session
|
|
|
|
chatApi.resumeSession.mockImplementationOnce((sessionId: string, onResumed: (data: any) => void) => {
|
|
onResumed({
|
|
session_id: sessionId,
|
|
messages: [
|
|
{ id: 1, role: 'user', content: 'Previous question', timestamp: 1 },
|
|
{ id: 2, role: 'assistant', content: 'Previous answer', timestamp: 2 },
|
|
],
|
|
parentSessionId: 'session-1',
|
|
forkPointMessageId: '2',
|
|
parentTitle: 'session',
|
|
parentLastMessage: 'Previous answer',
|
|
parentLastMessageRole: 'assistant',
|
|
messageLoadedCount: 2,
|
|
messageTotal: 2,
|
|
hasMoreBefore: false,
|
|
isWorking: false,
|
|
events: [],
|
|
queueLength: 0,
|
|
})
|
|
return {} as any
|
|
})
|
|
|
|
chatApi.sessionCommandHandlers[0]({
|
|
event: 'session.command',
|
|
session_id: 'session-1',
|
|
command: 'fork',
|
|
action: 'branch',
|
|
ok: true,
|
|
parentSessionId: 'session-1',
|
|
newSessionId: 'branch-1',
|
|
newSessionTitle: 'Side path',
|
|
branchSession: {
|
|
id: 'branch-1',
|
|
profile: 'default',
|
|
source: 'cli',
|
|
title: 'Side path',
|
|
model: 'openai/gpt-5.4',
|
|
provider: 'openai-codex',
|
|
parentSessionId: 'session-1',
|
|
forkPointMessageId: '2',
|
|
parentTitle: 'session',
|
|
parentLastMessage: 'Previous answer',
|
|
parentLastMessageRole: 'assistant',
|
|
createdAt: 1_700_000_000_000,
|
|
updatedAt: 1_700_000_000_000,
|
|
messageCount: 2,
|
|
workspace: '/repo',
|
|
},
|
|
message: 'Branched session "Side path" from session-1.',
|
|
})
|
|
await Promise.resolve()
|
|
|
|
const branch = store.sessions.find((item: Session) => item.id === 'branch-1')
|
|
expect(branch).toMatchObject({
|
|
title: 'Side path',
|
|
source: 'cli',
|
|
profile: 'default',
|
|
model: 'openai/gpt-5.4',
|
|
provider: 'openai-codex',
|
|
parentSessionId: 'session-1',
|
|
forkPointMessageId: '2',
|
|
parentTitle: 'session',
|
|
parentLastMessage: 'Previous answer',
|
|
parentLastMessageRole: 'assistant',
|
|
messageCount: 2,
|
|
workspace: '/repo',
|
|
})
|
|
expect(store.activeSessionId).toBe('branch-1')
|
|
expect(chatApi.resumeSession).toHaveBeenCalledWith('branch-1', expect.any(Function), 'default', 'chat-run')
|
|
|
|
await store.switchSession('session-1')
|
|
expect(store.activeSessionId).toBe('session-1')
|
|
expect(store.activeSession?.id).toBe('session-1')
|
|
expect(store.sessions.find((item: Session) => item.id === 'session-1')?.messages.at(-1)).toMatchObject({
|
|
role: 'command',
|
|
commandAction: 'branch',
|
|
content: 'Branched session "Side path" from session-1.',
|
|
})
|
|
|
|
await store.switchSession('branch-1')
|
|
expect(store.activeSessionId).toBe('branch-1')
|
|
expect(store.activeSession?.messages).toEqual([
|
|
expect.objectContaining({ role: 'user', content: 'Previous question' }),
|
|
expect.objectContaining({ role: 'assistant', content: 'Previous answer' }),
|
|
])
|
|
})
|
|
})
|