hololake-system-architecture/product-source/hololake-platform/src/lib/aiAgentConversation.test.ts

315 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
buildAgentSystemPromptMock,
formatMessageWithHistoryMock,
nextMessageIdMock,
trimHistoryMock,
} = vi.hoisted(() => ({
buildAgentSystemPromptMock: vi.fn(() => 'SYSTEM'),
formatMessageWithHistoryMock: vi.fn((_history: unknown, prompt: string) => `formatted:${prompt}`),
nextMessageIdMock: vi.fn(),
trimHistoryMock: vi.fn((history: unknown) => history),
}))
vi.mock('../utils/ai-agent', () => ({
buildAgentSystemPrompt: buildAgentSystemPromptMock,
}))
vi.mock('../utils/ai-chat', () => ({
MAX_HISTORY_TOKENS: 100_000,
formatMessageWithHistory: formatMessageWithHistoryMock,
nextMessageId: nextMessageIdMock,
trimHistory: trimHistoryMock,
}))
import {
appendLocalResponse,
appendStreamingMessage,
buildFormattedMessage,
createMissingAgentResponse,
type AiAgentMessage,
} from './aiAgentConversation'
import {
formatToolLabel,
markReasoningDone,
updateMessage,
updateToolAction,
} from './aiAgentMessageState'
function createMessageStore(initial: AiAgentMessage[] = []) {
let messages = initial
return {
getMessages: () => messages,
setMessages: (next: AiAgentMessage[] | ((current: AiAgentMessage[]) => AiAgentMessage[])) => {
messages = typeof next === 'function' ? next(messages) : next
},
}
}
describe('aiAgentConversation', () => {
beforeEach(() => {
vi.clearAllMocks()
buildAgentSystemPromptMock.mockReturnValue('SYSTEM')
formatMessageWithHistoryMock.mockImplementation((_history: unknown, prompt: string) => `formatted:${prompt}`)
trimHistoryMock.mockImplementation((history: unknown) => history)
})
it('creates a missing-agent response using the agent label', () => {
expect(createMissingAgentResponse('codex')).toContain('Codex is not available on this machine')
})
it('appends local responses with the normalized message shape', () => {
nextMessageIdMock.mockReturnValue('msg-local')
const store = createMessageStore()
appendLocalResponse(
store.setMessages,
{ text: 'Explain this', references: [{ path: '/vault/note.md', title: 'Note' }] },
'Sure',
)
expect(store.getMessages()).toEqual([
{
userMessage: 'Explain this',
references: [{ path: '/vault/note.md', title: 'Note' }],
actions: [],
response: 'Sure',
id: 'msg-local',
},
])
})
it('appends streaming messages and returns the generated message id', () => {
nextMessageIdMock.mockReturnValue('msg-stream')
const store = createMessageStore()
const messageId = appendStreamingMessage(store.setMessages, { text: 'Draft reply' })
expect(messageId).toBe('msg-stream')
expect(store.getMessages()).toEqual([
{
userMessage: 'Draft reply',
references: undefined,
actions: [],
isStreaming: true,
id: 'msg-stream',
},
])
})
it('builds a formatted message from completed history only', () => {
const messages: AiAgentMessage[] = [
{
id: 'msg-1',
userMessage: 'First question',
actions: [],
response: 'First answer',
},
{
id: 'msg-2',
userMessage: 'Still streaming',
actions: [],
isStreaming: true,
},
]
const result = buildFormattedMessage(
{ agent: 'codex', ready: true, vaultPath: '/vault', permissionMode: 'safe' },
messages,
{ text: 'Latest question' },
)
expect(buildAgentSystemPromptMock).toHaveBeenCalledWith({
agent: 'codex',
agentDocsPath: undefined,
permissionMode: 'safe',
vaultPaths: undefined,
vaultContext: undefined,
})
expect(trimHistoryMock).toHaveBeenCalledWith([
{ role: 'user', content: 'First question', id: 'msg-1' },
{ role: 'assistant', content: 'First answer', id: 'msg-1-resp' },
], 100_000)
expect(formatMessageWithHistoryMock).toHaveBeenCalledWith([
{ role: 'user', content: 'First question', id: 'msg-1' },
{ role: 'assistant', content: 'First answer', id: 'msg-1-resp' },
], 'Latest question')
expect(result).toEqual({
formattedMessage: 'formatted:Latest question',
systemPrompt: 'SYSTEM',
})
})
it('appends context snapshots to the mode-aware system prompt', () => {
const result = buildFormattedMessage(
{
agent: 'codex',
agentDocsPath: '/docs',
ready: true,
vaultPath: '/vault',
permissionMode: 'power_user',
systemPromptOverride: 'CONTEXT',
},
[],
{ text: 'Prompt' },
)
expect(buildAgentSystemPromptMock).toHaveBeenCalledWith({
agent: 'codex',
agentDocsPath: '/docs',
permissionMode: 'power_user',
vaultPaths: undefined,
vaultContext: 'CONTEXT',
})
expect(result.systemPrompt).toBe('SYSTEM')
})
it('formats explicit note references into the current prompt', () => {
buildFormattedMessage(
{ agent: 'codex', ready: true, vaultPath: '/vault', permissionMode: 'safe' },
[],
{
text: 'Use this note',
references: [{
path: '/vault/ref.md',
title: 'Ref',
type: 'Note',
content: 'Referenced body',
}],
},
)
expect(formatMessageWithHistoryMock).toHaveBeenCalledWith([], expect.stringContaining('Referenced body'))
expect(formatMessageWithHistoryMock).toHaveBeenCalledWith([], expect.stringContaining('/vault/ref.md'))
})
})
describe('aiAgentMessageState', () => {
it('updates only the targeted message', () => {
const store = createMessageStore([
{ id: 'keep', userMessage: 'Keep', actions: [] },
{ id: 'edit', userMessage: 'Edit', actions: [] },
])
updateMessage(store.setMessages, 'edit', (message) => ({
...message,
response: 'Updated',
}))
expect(store.getMessages()).toEqual([
{ id: 'keep', userMessage: 'Keep', actions: [] },
{ id: 'edit', userMessage: 'Edit', actions: [], response: 'Updated' },
])
})
it('marks reasoning as done only once', () => {
const store = createMessageStore([
{ id: 'done', userMessage: 'Question', actions: [], reasoningDone: true },
{ id: 'pending', userMessage: 'Another', actions: [] },
])
markReasoningDone(store.setMessages, 'done')
markReasoningDone(store.setMessages, 'pending')
expect(store.getMessages()).toEqual([
{ id: 'done', userMessage: 'Question', actions: [], reasoningDone: true },
{ id: 'pending', userMessage: 'Another', actions: [], reasoningDone: true },
])
})
it('adds new tool actions with the expected labels', () => {
const baseMessage: AiAgentMessage = {
id: 'msg',
userMessage: 'Question',
actions: [],
}
expect(updateToolAction(baseMessage, 'Bash', 'tool-1', 'ls')).toMatchObject({
actions: [{
tool: 'Bash',
toolId: 'tool-1',
label: 'Ran shell command',
status: 'pending',
input: 'ls',
}],
})
expect(updateToolAction(baseMessage, 'Write', 'tool-2', '{"path":"/tmp/a.md"}')).toMatchObject({
actions: [{
tool: 'Write',
toolId: 'tool-2',
label: 'Wrote file',
status: 'pending',
}],
})
expect(updateToolAction(baseMessage, 'Edit', 'tool-3', '{"path":"/tmp/a.md"}')).toMatchObject({
actions: [{
tool: 'Edit',
toolId: 'tool-3',
label: 'Edited file',
status: 'pending',
}],
})
})
it('updates an existing tool action without dropping the prior input', () => {
const message: AiAgentMessage = {
id: 'msg',
userMessage: 'Question',
actions: [{
tool: 'Write',
toolId: 'tool-1',
label: 'Wrote file',
status: 'pending',
input: '{"path":"/tmp/original.md"}',
}],
}
expect(updateToolAction(message, 'Write', 'tool-1')).toEqual({
...message,
actions: [{
tool: 'Write',
toolId: 'tool-1',
label: 'Wrote file',
status: 'pending',
input: '{"path":"/tmp/original.md"}',
}],
})
})
it('describes web and vault tools in Chinese with the current target', () => {
const baseMessage: AiAgentMessage = {
id: 'msg',
userMessage: '请调研',
actions: [],
}
expect(updateToolAction(
baseMessage,
'search_web',
'tool-search',
'{"query":"HoloLake Era 最新版本"}',
'zh-CN',
)).toMatchObject({
actions: [{ label: '正在联网搜索HoloLake Era 最新版本' }],
})
expect(updateToolAction(
baseMessage,
'read_web_page',
'tool-read',
'{"url":"https://example.com/news"}',
'zh-CN',
)).toMatchObject({
actions: [{ label: '正在浏览网页https://example.com/news' }],
})
expect(formatToolLabel(
'read_web_page',
'{"url":"https://example.com/news"}',
'zh-CN',
'done',
)).toBe('已浏览网页https://example.com/news')
})
})