mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
288 lines
9.2 KiB
TypeScript
288 lines
9.2 KiB
TypeScript
import { beforeEach, describe, expect, it } from 'vitest'
|
|
|
|
import type { ChatMessage } from '@/lib/chat-messages'
|
|
import { $approvalModes, approvalModeForProfile } from '@/store/approval-mode'
|
|
import { $activeGatewayProfile } from '@/store/profile'
|
|
import type { SessionInfo } from '@/types/hermes'
|
|
|
|
import {
|
|
applyRuntimeInfo,
|
|
chatMessageArraysEquivalent,
|
|
chatMessagesEquivalent,
|
|
chatPartsEquivalent,
|
|
isSessionGoneError,
|
|
reconcileResumeMessages,
|
|
sessionMatchesStoredId,
|
|
sessionShouldHaveTranscript,
|
|
toBranchMessages
|
|
} from './utils'
|
|
|
|
const msg = (id: string, role: ChatMessage['role'], text: string, extra: Partial<ChatMessage> = {}): ChatMessage =>
|
|
({ id, role, parts: [{ type: 'text', text }], ...extra }) as ChatMessage
|
|
|
|
const session = (over: Partial<SessionInfo>): SessionInfo => over as SessionInfo
|
|
|
|
describe('applyRuntimeInfo approval mode', () => {
|
|
beforeEach(() => {
|
|
$approvalModes.set({})
|
|
$activeGatewayProfile.set('work')
|
|
})
|
|
|
|
it('reconciles session.info against the gateway profile', () => {
|
|
applyRuntimeInfo({ approval_mode: 'smart', desktop_contract: 3 })
|
|
|
|
expect(approvalModeForProfile('work')).toBe('smart')
|
|
expect(approvalModeForProfile('default')).toBe('smart')
|
|
})
|
|
})
|
|
|
|
describe('isSessionGoneError', () => {
|
|
it('is true for 404 / session-not-found, false otherwise', () => {
|
|
expect(isSessionGoneError(new Error('Request failed 404'))).toBe(true)
|
|
expect(isSessionGoneError(new Error('Session not found'))).toBe(true)
|
|
expect(isSessionGoneError(new Error('ECONNREFUSED'))).toBe(false)
|
|
expect(isSessionGoneError(null)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('sessionMatchesStoredId', () => {
|
|
it('matches on live id or lineage root', () => {
|
|
expect(sessionMatchesStoredId(session({ id: 'a' }), 'a')).toBe(true)
|
|
expect(sessionMatchesStoredId(session({ id: 'live', _lineage_root_id: 'root' }), 'root')).toBe(true)
|
|
expect(sessionMatchesStoredId(session({ id: 'a' }), 'b')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('sessionShouldHaveTranscript', () => {
|
|
it('is true only when the session has messages', () => {
|
|
expect(sessionShouldHaveTranscript(session({ message_count: 3 }))).toBe(true)
|
|
expect(sessionShouldHaveTranscript(session({ message_count: 0 }))).toBe(false)
|
|
expect(sessionShouldHaveTranscript(undefined)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('toBranchMessages', () => {
|
|
it('keeps only user/assistant turns that carry text', () => {
|
|
const out = toBranchMessages([
|
|
msg('u', 'user', 'hi'),
|
|
msg('blank', 'assistant', ' '),
|
|
msg('sys', 'system', 'ignored'),
|
|
msg('a', 'assistant', 'hello')
|
|
])
|
|
|
|
expect(out.map(b => b.source.id)).toEqual(['u', 'a'])
|
|
expect(out[0]).toMatchObject({ content: 'hi', role: 'user' })
|
|
})
|
|
})
|
|
|
|
describe('chatPartsEquivalent', () => {
|
|
it('returns true for identical text parts', () => {
|
|
const partA = { type: 'text' as const, text: 'Hello world' }
|
|
const partB = { type: 'text' as const, text: 'Hello world' }
|
|
|
|
expect(chatPartsEquivalent(partA, partB)).toBe(true)
|
|
})
|
|
|
|
it('returns false for text parts with different content', () => {
|
|
const partA = { type: 'text' as const, text: 'Hello' }
|
|
const partB = { type: 'text' as const, text: 'World' }
|
|
|
|
expect(chatPartsEquivalent(partA, partB)).toBe(false)
|
|
})
|
|
|
|
it('returns true for identical reasoning parts', () => {
|
|
const partA = { type: 'reasoning' as const, text: 'Thinking...' }
|
|
const partB = { type: 'reasoning' as const, text: 'Thinking...' }
|
|
|
|
expect(chatPartsEquivalent(partA, partB)).toBe(true)
|
|
})
|
|
|
|
it('returns true for tool-call parts with same identity and both have no result', () => {
|
|
const partA = {
|
|
type: 'tool-call' as const,
|
|
toolCallId: 'tc-1',
|
|
toolName: 'read_file',
|
|
args: {} as never,
|
|
argsText: '{}'
|
|
}
|
|
const partB = {
|
|
type: 'tool-call' as const,
|
|
toolCallId: 'tc-1',
|
|
toolName: 'read_file',
|
|
args: {} as never,
|
|
argsText: '{}'
|
|
}
|
|
|
|
expect(chatPartsEquivalent(partA, partB)).toBe(true)
|
|
})
|
|
|
|
it('returns true for tool-call parts with same identity and both have results', () => {
|
|
const partA = {
|
|
type: 'tool-call' as const,
|
|
toolCallId: 'tc-1',
|
|
toolName: 'read_file',
|
|
args: {} as never,
|
|
argsText: '{}',
|
|
result: { content: 'file data' },
|
|
isError: false
|
|
}
|
|
const partB = {
|
|
type: 'tool-call' as const,
|
|
toolCallId: 'tc-1',
|
|
toolName: 'read_file',
|
|
args: {} as never,
|
|
argsText: '{}',
|
|
result: { content: 'file data' },
|
|
isError: false
|
|
}
|
|
|
|
expect(chatPartsEquivalent(partA, partB)).toBe(true)
|
|
})
|
|
|
|
it('returns false when only one tool-call part has a result', () => {
|
|
const partA = {
|
|
type: 'tool-call' as const,
|
|
toolCallId: 'tc-1',
|
|
toolName: 'read_file',
|
|
args: {} as never,
|
|
argsText: '{}'
|
|
}
|
|
const partB = {
|
|
type: 'tool-call' as const,
|
|
toolCallId: 'tc-1',
|
|
toolName: 'read_file',
|
|
args: {} as never,
|
|
argsText: '{}',
|
|
result: { content: 'file data' },
|
|
isError: false
|
|
}
|
|
|
|
expect(chatPartsEquivalent(partA, partB)).toBe(false)
|
|
})
|
|
|
|
it('uses reference equality fast-path for identical part objects', () => {
|
|
const part = { type: 'text' as const, text: 'Same reference' }
|
|
|
|
expect(chatPartsEquivalent(part, part)).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('chatMessagesEquivalent', () => {
|
|
it('returns true for structurally identical messages', () => {
|
|
expect(chatMessagesEquivalent(msg('1', 'user', 'Hello'), msg('1', 'user', 'Hello'))).toBe(true)
|
|
})
|
|
|
|
it('returns false when text part content differs', () => {
|
|
expect(chatMessagesEquivalent(msg('1', 'user', 'Hello'), msg('1', 'user', 'World'))).toBe(false)
|
|
})
|
|
|
|
it('returns false when tool result presence differs', () => {
|
|
const messageA: ChatMessage = {
|
|
id: 'msg-1',
|
|
role: 'assistant',
|
|
parts: [{ type: 'tool-call', toolCallId: 'tc-1', toolName: 'read_file', args: {} as never, argsText: '{}' }]
|
|
}
|
|
|
|
const messageB: ChatMessage = {
|
|
id: 'msg-1',
|
|
role: 'assistant',
|
|
parts: [
|
|
{
|
|
type: 'tool-call',
|
|
toolCallId: 'tc-1',
|
|
toolName: 'read_file',
|
|
args: {} as never,
|
|
argsText: '{}',
|
|
result: { content: 'data' },
|
|
isError: false
|
|
}
|
|
]
|
|
}
|
|
|
|
expect(chatMessagesEquivalent(messageA, messageB)).toBe(false)
|
|
})
|
|
|
|
it('returns false when message IDs differ', () => {
|
|
expect(chatMessagesEquivalent(msg('msg-1', 'user', 'Hello'), msg('msg-2', 'user', 'Hello'))).toBe(false)
|
|
})
|
|
|
|
it('compares large messages with embedded images structurally without JSON.stringify', () => {
|
|
// Verifies that two structurally identical messages (that would be equal
|
|
// via stringify) are also equal via the new cheap structural compare.
|
|
const messageA: ChatMessage = {
|
|
id: 'msg-1',
|
|
role: 'assistant',
|
|
parts: [
|
|
{ type: 'text', text: 'Here are the images:' },
|
|
{
|
|
type: 'tool-call',
|
|
toolCallId: 'img-1',
|
|
toolName: 'image_generate',
|
|
args: { prompt: 'a cat' } as never,
|
|
argsText: '{"prompt":"a cat"}',
|
|
result: { image: 'data:image/png;base64,iVBORw0KG...(large base64)' },
|
|
isError: false
|
|
}
|
|
]
|
|
}
|
|
|
|
const messageB: ChatMessage = {
|
|
id: 'msg-1',
|
|
role: 'assistant',
|
|
parts: [
|
|
{ type: 'text', text: 'Here are the images:' },
|
|
{
|
|
type: 'tool-call',
|
|
toolCallId: 'img-1',
|
|
toolName: 'image_generate',
|
|
args: { prompt: 'a cat' } as never,
|
|
argsText: '{"prompt":"a cat"}',
|
|
result: { image: 'data:image/png;base64,iVBORw0KG...(large base64)' },
|
|
isError: false
|
|
}
|
|
]
|
|
}
|
|
|
|
// The structural compare treats these as equal (both have result defined,
|
|
// same toolCallId/toolName), without comparing the full result object.
|
|
expect(chatMessagesEquivalent(messageA, messageB)).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('chatMessageArraysEquivalent', () => {
|
|
it('returns true for identical arrays via identity fast-path', () => {
|
|
const messages: ChatMessage[] = [msg('1', 'user', 'x')]
|
|
|
|
expect(chatMessageArraysEquivalent(messages, messages)).toBe(true)
|
|
})
|
|
|
|
it('compares length and per-message equivalence', () => {
|
|
const a = [msg('1', 'user', 'x'), msg('2', 'assistant', 'y')]
|
|
expect(chatMessageArraysEquivalent(a, [msg('1', 'user', 'x'), msg('2', 'assistant', 'y')])).toBe(true)
|
|
expect(chatMessageArraysEquivalent(a, [msg('1', 'user', 'x')])).toBe(false)
|
|
expect(chatMessageArraysEquivalent(a, [msg('1', 'user', 'x'), msg('2', 'assistant', 'changed')])).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('reconcileResumeMessages', () => {
|
|
it('returns next untouched when there is no previous transcript', () => {
|
|
const next = [msg('1', 'user', 'hi')]
|
|
expect(reconcileResumeMessages(next, [])).toBe(next)
|
|
})
|
|
|
|
it('re-grafts reasoning parts onto a matching assistant turn', () => {
|
|
const next = [msg('a', 'assistant', 'answer')]
|
|
|
|
const previous = [
|
|
msg('a', 'assistant', 'answer', {
|
|
parts: [
|
|
{ type: 'reasoning', text: 'thinking' },
|
|
{ type: 'text', text: 'answer' }
|
|
]
|
|
} as Partial<ChatMessage>)
|
|
]
|
|
|
|
const [out] = reconcileResumeMessages(next, previous)
|
|
expect(out.parts.some(p => p.type === 'reasoning')).toBe(true)
|
|
})
|
|
})
|