242 lines
7.6 KiB
TypeScript
242 lines
7.6 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { AgentStatus, AiAgentMessage } from './aiAgentConversation'
|
|
|
|
const {
|
|
buildAgentSystemPromptMock,
|
|
createStreamCallbacksMock,
|
|
formatMessageWithHistoryMock,
|
|
nextMessageIdMock,
|
|
streamAiAgentMock,
|
|
trimHistoryMock,
|
|
} = vi.hoisted(() => ({
|
|
buildAgentSystemPromptMock: vi.fn(() => 'SYSTEM'),
|
|
createStreamCallbacksMock: vi.fn(() => ({ stream: 'callbacks' })),
|
|
formatMessageWithHistoryMock: vi.fn((_history: unknown, prompt: string) => `formatted:${prompt}`),
|
|
nextMessageIdMock: vi.fn(),
|
|
streamAiAgentMock: vi.fn(async () => {}),
|
|
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,
|
|
}))
|
|
|
|
vi.mock('./aiAgentStreamCallbacks', () => ({
|
|
createStreamCallbacks: createStreamCallbacksMock,
|
|
}))
|
|
|
|
vi.mock('../utils/streamAiAgent', () => ({
|
|
streamAiAgent: streamAiAgentMock,
|
|
}))
|
|
|
|
import {
|
|
clearAgentConversation,
|
|
sendAgentMessage,
|
|
type AiAgentSessionRuntime,
|
|
} from './aiAgentSession'
|
|
|
|
function createRuntime(
|
|
initialMessages: AiAgentMessage[] = [],
|
|
initialStatus: AgentStatus = 'idle',
|
|
) {
|
|
let messages = initialMessages
|
|
let status = initialStatus
|
|
|
|
const messagesRef = { current: messages }
|
|
const statusRef = { current: status }
|
|
|
|
const setMessages = vi.fn((next: AiAgentMessage[] | ((current: AiAgentMessage[]) => AiAgentMessage[])) => {
|
|
messages = typeof next === 'function' ? next(messages) : next
|
|
messagesRef.current = messages
|
|
})
|
|
const setStatus = vi.fn((next: AgentStatus | ((current: AgentStatus) => AgentStatus)) => {
|
|
status = typeof next === 'function' ? next(status) : next
|
|
statusRef.current = status
|
|
})
|
|
|
|
const runtime: AiAgentSessionRuntime = {
|
|
setMessages,
|
|
setStatus,
|
|
abortRef: { current: { aborted: true } },
|
|
responseAccRef: { current: 'stale response' },
|
|
fileCallbacksRef: { current: { onVaultChanged: vi.fn() } },
|
|
toolInputMapRef: { current: new Map([['stale-tool', { tool: 'Write', input: '{"path":"/stale.md"}' }]]) },
|
|
messagesRef,
|
|
statusRef,
|
|
}
|
|
|
|
return {
|
|
runtime,
|
|
getMessages: () => messages,
|
|
getStatus: () => status,
|
|
}
|
|
}
|
|
|
|
describe('aiAgentSession', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
buildAgentSystemPromptMock.mockReturnValue('SYSTEM')
|
|
createStreamCallbacksMock.mockReturnValue({ stream: 'callbacks' })
|
|
formatMessageWithHistoryMock.mockImplementation((_history: unknown, prompt: string) => `formatted:${prompt}`)
|
|
trimHistoryMock.mockImplementation((history: unknown) => history)
|
|
streamAiAgentMock.mockResolvedValue(undefined)
|
|
})
|
|
|
|
async function expectLocalResponse(options: {
|
|
messageId: string
|
|
context: { agent: string; ready: boolean; vaultPath: string }
|
|
prompt: { text: string; references?: [] }
|
|
response: string
|
|
}) {
|
|
nextMessageIdMock.mockReturnValue(options.messageId)
|
|
const { runtime, getMessages } = createRuntime()
|
|
|
|
await sendAgentMessage({
|
|
runtime,
|
|
context: options.context,
|
|
prompt: options.prompt,
|
|
})
|
|
|
|
expect(getMessages()).toEqual([
|
|
{
|
|
userMessage: options.prompt.text,
|
|
references: undefined,
|
|
actions: [],
|
|
response: options.response,
|
|
id: options.messageId,
|
|
},
|
|
])
|
|
expect(streamAiAgentMock).not.toHaveBeenCalled()
|
|
}
|
|
|
|
it('ignores blank prompts and busy runtimes', async () => {
|
|
const idleRuntime = createRuntime()
|
|
await sendAgentMessage({
|
|
runtime: idleRuntime.runtime,
|
|
context: { agent: 'codex', ready: true, vaultPath: '/vault' },
|
|
prompt: { text: ' ' },
|
|
})
|
|
|
|
const busyRuntime = createRuntime([], 'thinking')
|
|
await sendAgentMessage({
|
|
runtime: busyRuntime.runtime,
|
|
context: { agent: 'codex', ready: true, vaultPath: '/vault' },
|
|
prompt: { text: 'Question' },
|
|
})
|
|
|
|
expect(idleRuntime.getMessages()).toEqual([])
|
|
expect(busyRuntime.getMessages()).toEqual([])
|
|
expect(streamAiAgentMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('appends local fallback responses when the session cannot stream', async () => {
|
|
const fallbackCases = [
|
|
{
|
|
messageId: 'msg-local',
|
|
context: { agent: 'codex', ready: true, vaultPath: '' },
|
|
prompt: { text: 'Open a note' },
|
|
response: 'No vault loaded. Open a vault first.',
|
|
},
|
|
{
|
|
messageId: 'msg-missing',
|
|
context: { agent: 'codex', ready: false, vaultPath: '/vault' },
|
|
prompt: { text: 'Open a note', references: [] },
|
|
response:
|
|
'Codex is not available on this machine. Install it or switch the default AI agent in Settings.',
|
|
},
|
|
] as const
|
|
|
|
for (const fallbackCase of fallbackCases) {
|
|
await expectLocalResponse(fallbackCase)
|
|
}
|
|
})
|
|
|
|
it('starts a streaming session with formatted history and fresh refs', async () => {
|
|
nextMessageIdMock.mockReturnValue('msg-stream')
|
|
const completedHistory: AiAgentMessage = {
|
|
id: 'msg-1',
|
|
userMessage: 'Previous question',
|
|
actions: [],
|
|
response: 'Previous answer',
|
|
}
|
|
const streamingHistory: AiAgentMessage = {
|
|
id: 'msg-2',
|
|
userMessage: 'Ignored streaming question',
|
|
actions: [],
|
|
isStreaming: true,
|
|
}
|
|
const { runtime, getMessages, getStatus } = createRuntime([
|
|
completedHistory,
|
|
streamingHistory,
|
|
])
|
|
|
|
await sendAgentMessage({
|
|
runtime,
|
|
context: {
|
|
agent: 'codex',
|
|
ready: true,
|
|
vaultPath: '/vault',
|
|
systemPromptOverride: 'OVERRIDE',
|
|
},
|
|
prompt: {
|
|
text: ' Latest question ',
|
|
references: [{ path: '/vault/ref.md', title: 'Ref' }],
|
|
},
|
|
})
|
|
|
|
expect(runtime.abortRef.current).toEqual({ aborted: false })
|
|
expect(runtime.responseAccRef.current).toBe('')
|
|
expect(runtime.toolInputMapRef.current.size).toBe(0)
|
|
expect(getStatus()).toBe('thinking')
|
|
expect(getMessages().at(-1)).toEqual({
|
|
userMessage: 'Latest question',
|
|
references: [{ path: '/vault/ref.md', title: 'Ref' }],
|
|
actions: [],
|
|
isStreaming: true,
|
|
id: 'msg-stream',
|
|
})
|
|
expect(trimHistoryMock).toHaveBeenCalledWith([
|
|
{ role: 'user', content: 'Previous question', id: 'msg-1' },
|
|
{ role: 'assistant', content: 'Previous answer', id: 'msg-1-resp' },
|
|
], 100_000)
|
|
expect(formatMessageWithHistoryMock).toHaveBeenCalledWith([
|
|
{ role: 'user', content: 'Previous question', id: 'msg-1' },
|
|
{ role: 'assistant', content: 'Previous answer', id: 'msg-1-resp' },
|
|
], 'Latest question')
|
|
expect(createStreamCallbacksMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
messageId: 'msg-stream',
|
|
vaultPath: '/vault',
|
|
setMessages: runtime.setMessages,
|
|
setStatus: runtime.setStatus,
|
|
}))
|
|
expect(streamAiAgentMock).toHaveBeenCalledWith({
|
|
agent: 'codex',
|
|
message: 'formatted:Latest question',
|
|
systemPrompt: 'OVERRIDE',
|
|
vaultPath: '/vault',
|
|
callbacks: { stream: 'callbacks' },
|
|
})
|
|
})
|
|
|
|
it('clears the conversation and resets runtime refs', () => {
|
|
const { runtime } = createRuntime([
|
|
{ id: 'msg-1', userMessage: 'Question', actions: [] },
|
|
], 'done')
|
|
|
|
clearAgentConversation(runtime)
|
|
|
|
expect(runtime.abortRef.current.aborted).toBe(true)
|
|
expect(runtime.responseAccRef.current).toBe('')
|
|
expect(runtime.toolInputMapRef.current.size).toBe(0)
|
|
expect(runtime.setMessages).toHaveBeenCalledWith([])
|
|
expect(runtime.setStatus).toHaveBeenCalledWith('idle')
|
|
})
|
|
})
|