mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-07-16 13:40:14 +00:00
5eb1b94138
* chore: initialize upstream-based workflow orchestration v2 draft * feat(workflow): validate declarative edge orchestration * feat(workflow): evaluate edge conditions safely * feat(workflow): support declarative condition operators * feat(workflow): evaluate edge route decisions * feat(workflow): skip unmatched success branches * feat(workflow): route failed node outcomes * feat(workflow): validate node join mode * feat(workflow): support all and any joins * feat(workflow): schedule nodes on completion * feat(workflow): store append-only edge evidence * feat(workflow): persist edge decisions fail closed * feat(workflow): expose edge evidence in run history * feat(workflow): validate bounded feedback edges * feat(workflow): compile single-entry natural loops * feat(workflow): validate laminar nested loops * feat(workflow): fail closed during graph preflight * feat(workflow): preflight reruns and freeze loops * feat(workflow): identify loop execution instances * feat(workflow): execute bounded top-level loops * feat(workflow): audit conditional loop exits * feat(workflow): record loop iteration limits * feat(workflow): identify edge iteration evidence * feat(workflow): audit loop forward edges * feat(workflow): persist loop epoch evidence * feat(workflow): expose loop epochs in run history * feat(workflow): record failed loop epochs * feat(workflow): audit canceled loop epochs * feat(workflow): audit loop agent timeouts * feat(workflow): gate loop executions on approval * feat(workflow): enforce run execution budget * feat(workflow): enforce loop run deadline * feat(workflow): bound loop approval waits * feat(workflow): enforce DAG run deadline * feat(workflow): enforce rerun deadline * feat(workflow): guard rerun execution budget * feat(workflow): run one downstream node after loop exit * feat(workflow): dispatch persisted loop exit targets * feat(workflow): honor conditional routing inside loop bodies * feat(workflow): continue through post-loop DAGs * feat(workflow): route post-loop failures * feat(workflow): add secure definition portability * feat(workflow): expose portability client API * feat(workflow): add import and export controls * feat(workflow): execute disjoint and nested loops * feat(workflow): edit edge orchestration from canvas * feat(workflow): show execution evidence in run history * feat(workflow): edit node join policy * feat(workflow): create connected nodes atomically * test(workflow): cover orchestration canvas in browser * refactor(workflow): unify laminar loop runtime * fix(workflow): bind approvals to executions * test(workflow): verify canvas interactions in browser * feat(workflow): enforce execution identity policies * feat(workflow): harden lifecycle recovery * refactor(workflow): share fresh and rerun schedulers * feat(workflow): harden orchestration evidence and portability * fix(workflow): close final acceptance and canvas gaps * test(workflow): add disposable live acceptance * fix(workflow): migrate legacy execution evidence * refactor(workflow): use icons for import and export * refactor(workflow): use ctrl-z for canvas undo * refactor(workflow): restore profile-default agent context * fix(workflow): strip legacy policy from edge node copies * feat(workflow): guide ordinary users through routing * refactor(workflow): move field help into tooltips * feat(workflow): validate typed condition values * feat(chat): align maximum reasoning effort * test(workflow): isolate manager SQLite runtime Keep the WorkflowManager suite on a disposable SQLite directory so coverage workers cannot race the shared test-runtime database. Production and development database paths are unchanged. * fix(workflow): keep Hermes API mode profile-owned * fix(workflow): make imports portable across environments Strip source runtime bindings from exports and legacy imports while keeping capability and skill checks at execution time. Address runtime subscription, feedback routing, approval cancellation, durable Session evidence, SQLite index migration, and compact evidence rendering. * fix(bridge): reject unsupported reasoning effort Keep Hermes API mode profile-owned while restoring explicit failure when the runtime cannot parse or apply a requested reasoning effort. * test(workflow): use writable Session fixture workspace * fix(workflow): align import export icon semantics * feat(workflow): make execution details user friendly * fix workflow replay and presentation regressions --------- Co-authored-by: Lux <wangw9475@agent.qq.com> Co-authored-by: ekko <fqsy1416@gmail.com>
522 lines
16 KiB
TypeScript
522 lines
16 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('forwards maximum reasoning effort from the active session to the run request', async () => {
|
|
const store = useChatStore()
|
|
const session = makeSession()
|
|
session.source = 'cli'
|
|
session.reasoningEffort = 'max'
|
|
store.sessions = [session]
|
|
store.activeSessionId = 'session-1'
|
|
store.activeSession = session
|
|
|
|
await store.sendMessage('use the maximum reasoning budget')
|
|
|
|
expect(chatApi.startRunViaSocket).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
input: 'use the maximum reasoning budget',
|
|
session_id: 'session-1',
|
|
reasoning_effort: 'max',
|
|
}),
|
|
expect.any(Function),
|
|
expect.any(Function),
|
|
expect.any(Function),
|
|
undefined,
|
|
expect.any(Object),
|
|
)
|
|
})
|
|
|
|
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' }),
|
|
])
|
|
})
|
|
})
|