315 lines
8.9 KiB
TypeScript
315 lines
8.9 KiB
TypeScript
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')
|
||
})
|
||
})
|