test(stage-ui): add contract test (#1487)
This commit is contained in:
@@ -84,9 +84,9 @@ describe('useVisionInference', () => {
|
||||
imageDataUrl: 'data:image/png;base64,Zm9v',
|
||||
workloadId: 'screen:interpret',
|
||||
})
|
||||
const expectation = expect(result).rejects.toThrow('Vision inference timed out after 15000ms')
|
||||
const expectation = expect(result).rejects.toThrow('Vision inference timed out after 60000ms')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(15_000)
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
|
||||
await expectation
|
||||
})
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
import type { ChatProvider } from '@xsai-ext/providers/utils'
|
||||
import type { Message } from '@xsai/shared-chat'
|
||||
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useChatOrchestratorStore } from './chat'
|
||||
|
||||
const llmStreamMock = vi.fn()
|
||||
const trackFirstMessageMock = vi.fn()
|
||||
const ingestContextMessageMock = vi.fn()
|
||||
const getContextsSnapshotMock = vi.fn()
|
||||
const createDatetimeContextMock = vi.fn()
|
||||
const createMinecraftContextMock = vi.fn()
|
||||
const persistSessionMessagesMock = vi.fn()
|
||||
const forkSessionMock = vi.fn()
|
||||
const ensureSessionMock = vi.fn()
|
||||
const parserConsumeMock = vi.fn()
|
||||
const parserEndMock = vi.fn()
|
||||
|
||||
const activeSessionIdRef = ref('session-1')
|
||||
const streamingMessageRef = ref<any>({ role: 'assistant', content: '', slices: [], tool_results: [] })
|
||||
const sessionMessages: Record<string, any[]> = {}
|
||||
let currentGeneration = 1
|
||||
|
||||
vi.mock('pinia', async () => {
|
||||
const actual = await vi.importActual<typeof import('pinia')>('pinia')
|
||||
return {
|
||||
...actual,
|
||||
storeToRefs: (store: any) => store,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@proj-airi/stream-kit', () => ({
|
||||
createQueue: ({ handlers }: { handlers: Array<(ctx: { data: any }) => Promise<void> | void> }) => {
|
||||
const enqueueListeners: Array<(data: any) => void> = []
|
||||
const dequeueListeners: Array<(data: any) => void> = []
|
||||
|
||||
return {
|
||||
enqueue(data: any) {
|
||||
for (const listener of enqueueListeners)
|
||||
listener(data)
|
||||
|
||||
queueMicrotask(async () => {
|
||||
try {
|
||||
for (const handler of handlers) {
|
||||
await handler({ data })
|
||||
}
|
||||
}
|
||||
finally {
|
||||
for (const listener of dequeueListeners)
|
||||
listener(data)
|
||||
}
|
||||
})
|
||||
},
|
||||
on(event: 'enqueue' | 'dequeue', listener: (data: any) => void) {
|
||||
if (event === 'enqueue') {
|
||||
enqueueListeners.push(listener)
|
||||
return
|
||||
}
|
||||
|
||||
dequeueListeners.push(listener)
|
||||
},
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../composables', () => ({
|
||||
useAnalytics: () => ({
|
||||
trackFirstMessage: trackFirstMessageMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../composables/llm-marker-parser', () => ({
|
||||
useLlmmarkerParser: (options: { onLiteral?: (literal: string) => Promise<void>, onEnd?: (fullText: string) => Promise<void> }) => {
|
||||
let fullText = ''
|
||||
return {
|
||||
consume: async (textPart: string) => {
|
||||
parserConsumeMock(textPart)
|
||||
fullText += textPart
|
||||
await options.onLiteral?.(textPart)
|
||||
},
|
||||
end: async () => {
|
||||
parserEndMock()
|
||||
await options.onEnd?.(fullText)
|
||||
},
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../composables/response-categoriser', () => ({
|
||||
createStreamingCategorizer: () => ({
|
||||
consume: vi.fn(),
|
||||
filterToSpeech: (literal: string) => literal,
|
||||
}),
|
||||
categorizeResponse: (fullText: string) => ({
|
||||
speech: fullText,
|
||||
reasoning: '',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('./chat/context-providers', () => ({
|
||||
createDatetimeContext: () => createDatetimeContextMock(),
|
||||
createMinecraftContext: () => createMinecraftContextMock(),
|
||||
}))
|
||||
|
||||
vi.mock('./chat/context-store', () => ({
|
||||
useChatContextStore: () => ({
|
||||
ingestContextMessage: ingestContextMessageMock,
|
||||
getContextsSnapshot: getContextsSnapshotMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('./chat/session-store', () => ({
|
||||
useChatSessionStore: () => ({
|
||||
activeSessionId: activeSessionIdRef,
|
||||
sessionMessages,
|
||||
ensureSession: (sessionId: string) => {
|
||||
ensureSessionMock(sessionId)
|
||||
sessionMessages[sessionId] ??= [{ role: 'system', content: 'system prompt', createdAt: 1, id: 'system' }]
|
||||
},
|
||||
appendSessionMessage: (sessionId: string, message: any) => {
|
||||
sessionMessages[sessionId] ??= []
|
||||
sessionMessages[sessionId].push(message)
|
||||
},
|
||||
getSessionMessages: (sessionId: string) => sessionMessages[sessionId] ?? [],
|
||||
persistSessionMessages: persistSessionMessagesMock,
|
||||
getSessionGeneration: () => currentGeneration,
|
||||
forkSession: forkSessionMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('./chat/stream-store', () => ({
|
||||
useChatStreamStore: () => ({
|
||||
streamingMessage: streamingMessageRef,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('./llm', () => ({
|
||||
useLLM: () => ({
|
||||
stream: llmStreamMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('./modules/consciousness', () => ({
|
||||
useConsciousnessStore: () => ({
|
||||
activeProvider: ref('mock-provider'),
|
||||
}),
|
||||
}))
|
||||
|
||||
const provider = {
|
||||
chat: () => ({ baseURL: 'https://example.com/' }),
|
||||
} as unknown as ChatProvider
|
||||
|
||||
describe('chat orchestrator contract', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
llmStreamMock.mockReset()
|
||||
trackFirstMessageMock.mockReset()
|
||||
ingestContextMessageMock.mockReset()
|
||||
getContextsSnapshotMock.mockReset()
|
||||
createDatetimeContextMock.mockReset()
|
||||
createMinecraftContextMock.mockReset()
|
||||
createMinecraftContextMock.mockReturnValue(undefined)
|
||||
persistSessionMessagesMock.mockReset()
|
||||
forkSessionMock.mockReset()
|
||||
ensureSessionMock.mockReset()
|
||||
parserConsumeMock.mockReset()
|
||||
parserEndMock.mockReset()
|
||||
activeSessionIdRef.value = 'session-1'
|
||||
streamingMessageRef.value = { role: 'assistant', content: '', slices: [], tool_results: [] }
|
||||
currentGeneration = 1
|
||||
|
||||
for (const key of Object.keys(sessionMessages)) {
|
||||
delete sessionMessages[key]
|
||||
}
|
||||
|
||||
sessionMessages['session-1'] = [{ role: 'system', content: 'system prompt', createdAt: 1, id: 'system' }]
|
||||
})
|
||||
|
||||
it('keeps hook order and composes context prompt after system message', async () => {
|
||||
const datetimeContext = {
|
||||
id: 'datetime',
|
||||
contextId: 'datetime',
|
||||
source: 'ReplaceSelf',
|
||||
content: 'now',
|
||||
createdAt: 123,
|
||||
}
|
||||
const contextsSnapshot = {
|
||||
weather: [
|
||||
{
|
||||
id: 'weather',
|
||||
contextId: 'weather',
|
||||
source: 'ReplaceSelf',
|
||||
content: 'sunny',
|
||||
createdAt: 456,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
createDatetimeContextMock.mockReturnValue(datetimeContext)
|
||||
getContextsSnapshotMock.mockReturnValue(contextsSnapshot)
|
||||
|
||||
let composedMessages: Message[] = []
|
||||
llmStreamMock.mockImplementation(async (_model: string, _chatProvider: ChatProvider, messages: Message[], options: any) => {
|
||||
composedMessages = messages
|
||||
expect(options.waitForTools).toBe(true)
|
||||
|
||||
await options.onStreamEvent({ type: 'text-delta', text: 'hello' })
|
||||
await options.onStreamEvent({ type: 'finish', finishReason: 'stop' })
|
||||
})
|
||||
|
||||
const store = useChatOrchestratorStore()
|
||||
const hookOrder: string[] = []
|
||||
|
||||
store.onBeforeMessageComposed(async () => {
|
||||
hookOrder.push('before-compose')
|
||||
})
|
||||
store.onAfterMessageComposed(async () => {
|
||||
hookOrder.push('after-compose')
|
||||
})
|
||||
store.onBeforeSend(async () => {
|
||||
hookOrder.push('before-send')
|
||||
})
|
||||
store.onTokenLiteral(async () => {
|
||||
hookOrder.push('token-literal')
|
||||
})
|
||||
store.onStreamEnd(async () => {
|
||||
hookOrder.push('stream-end')
|
||||
})
|
||||
store.onAssistantResponseEnd(async () => {
|
||||
hookOrder.push('assistant-end')
|
||||
})
|
||||
store.onAfterSend(async () => {
|
||||
hookOrder.push('after-send')
|
||||
})
|
||||
store.onAssistantMessage(async () => {
|
||||
hookOrder.push('assistant-message')
|
||||
})
|
||||
store.onChatTurnComplete(async () => {
|
||||
hookOrder.push('turn-complete')
|
||||
})
|
||||
|
||||
await store.ingest('hello from user', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
})
|
||||
|
||||
expect(store.sending).toBe(false)
|
||||
expect(trackFirstMessageMock).toHaveBeenCalledTimes(1)
|
||||
expect(ingestContextMessageMock).toHaveBeenCalledWith(datetimeContext)
|
||||
expect(persistSessionMessagesMock).not.toHaveBeenCalled()
|
||||
expect(parserConsumeMock).toHaveBeenCalledWith('hello')
|
||||
expect(parserEndMock).toHaveBeenCalledTimes(1)
|
||||
expect(hookOrder).toEqual([
|
||||
'before-compose',
|
||||
'after-compose',
|
||||
'before-send',
|
||||
'token-literal',
|
||||
'stream-end',
|
||||
'assistant-end',
|
||||
'after-send',
|
||||
'assistant-message',
|
||||
'turn-complete',
|
||||
])
|
||||
|
||||
expect(composedMessages).toHaveLength(2)
|
||||
expect(composedMessages[0]).toMatchObject({ role: 'system' })
|
||||
expect(composedMessages[1]).toMatchObject({ role: 'user' })
|
||||
const userMessageContent = (composedMessages[1] as any).content
|
||||
|
||||
expect(userMessageContent[0].text).toBe('hello from user')
|
||||
|
||||
const syntheticContextText = userMessageContent[1].text
|
||||
expect(syntheticContextText).toContain('<context>')
|
||||
expect(syntheticContextText).toContain('<module name="weather">')
|
||||
})
|
||||
|
||||
it('rejects cancelled queued sends before they start', async () => {
|
||||
llmStreamMock.mockImplementation(async () => {
|
||||
// keep pending
|
||||
await new Promise(() => {})
|
||||
})
|
||||
|
||||
const store = useChatOrchestratorStore()
|
||||
const pending = store.ingest('cancel me', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
})
|
||||
|
||||
store.cancelPendingSends('session-1')
|
||||
|
||||
await expect(pending).rejects.toThrow('Chat session was reset before send could start')
|
||||
})
|
||||
|
||||
it('rejects stale generation sends before performSend starts', async () => {
|
||||
const store = useChatOrchestratorStore()
|
||||
const pending = store.ingest('stale request', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
})
|
||||
|
||||
currentGeneration = 2
|
||||
|
||||
await expect(pending).rejects.toThrow('Chat session was reset before send could start')
|
||||
expect(llmStreamMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses forked session id in ingestOnFork and keeps public store contract keys', async () => {
|
||||
getContextsSnapshotMock.mockReturnValue({})
|
||||
forkSessionMock.mockResolvedValue('session-forked')
|
||||
llmStreamMock.mockImplementation(async (_model: string, _chatProvider: ChatProvider, _messages: Message[], options: any) => {
|
||||
await options.onStreamEvent({ type: 'text-delta', text: 'fork-reply' })
|
||||
await options.onStreamEvent({ type: 'finish', finishReason: 'stop' })
|
||||
})
|
||||
|
||||
const store = useChatOrchestratorStore()
|
||||
|
||||
expect(store.$id).toBe('chat-orchestrator')
|
||||
expect(typeof store.ingest).toBe('function')
|
||||
expect(typeof store.ingestOnFork).toBe('function')
|
||||
expect(typeof store.cancelPendingSends).toBe('function')
|
||||
expect(typeof store.onBeforeSend).toBe('function')
|
||||
expect(typeof store.emitBeforeSendHooks).toBe('function')
|
||||
|
||||
await store.ingestOnFork('fork me', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
}, {
|
||||
fromSessionId: 'session-1',
|
||||
atIndex: 3,
|
||||
reason: 'retry',
|
||||
hidden: true,
|
||||
})
|
||||
|
||||
expect(forkSessionMock).toHaveBeenCalledWith({
|
||||
fromSessionId: 'session-1',
|
||||
atIndex: 3,
|
||||
reason: 'retry',
|
||||
hidden: true,
|
||||
})
|
||||
expect(ensureSessionMock).toHaveBeenCalledWith('session-forked')
|
||||
})
|
||||
})
|
||||
@@ -47,4 +47,44 @@ describe('mergeLoadedSessionMessages', () => {
|
||||
|
||||
assert.equal(mergeLoadedSessionMessages(storedMessages, currentMessages), storedMessages)
|
||||
})
|
||||
|
||||
it('keeps a system message when storage is empty and current has in-flight user messages', () => {
|
||||
const storedMessages: ChatHistoryItem[] = []
|
||||
const currentMessages: ChatHistoryItem[] = [
|
||||
{ role: 'system', content: 'system from memory', createdAt: 1, id: 'system-current' },
|
||||
{ role: 'user', content: 'in-flight prompt', createdAt: 2, id: 'user-1' },
|
||||
]
|
||||
|
||||
assert.deepEqual(mergeLoadedSessionMessages(storedMessages, currentMessages), [
|
||||
currentMessages[0],
|
||||
currentMessages[1],
|
||||
])
|
||||
})
|
||||
|
||||
it('uses flattened array text for deduplication fingerprints', () => {
|
||||
const storedMessages: ChatHistoryItem[] = [
|
||||
{ role: 'system', content: 'system', createdAt: 1, id: 'system' },
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'hello' },
|
||||
{ type: 'text', text: ' world' },
|
||||
],
|
||||
createdAt: 5,
|
||||
},
|
||||
]
|
||||
|
||||
const currentMessages: ChatHistoryItem[] = [
|
||||
{ role: 'system', content: 'system', createdAt: 2, id: 'system-memory' },
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'hello world' },
|
||||
],
|
||||
createdAt: 5,
|
||||
},
|
||||
]
|
||||
|
||||
assert.equal(mergeLoadedSessionMessages(storedMessages, currentMessages), storedMessages)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const currentFilePath = fileURLToPath(import.meta.url)
|
||||
const currentDirectory = dirname(currentFilePath)
|
||||
const packageJsonPath = resolve(currentDirectory, '../../package.json')
|
||||
|
||||
function readExports() {
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
|
||||
exports: Record<string, string>
|
||||
}
|
||||
return packageJson.exports
|
||||
}
|
||||
|
||||
describe('stage-ui exports contract', () => {
|
||||
it('keeps the exported subpath key set stable', () => {
|
||||
const exportsMap = readExports()
|
||||
|
||||
expect(Object.keys(exportsMap).sort()).toEqual([
|
||||
'.',
|
||||
'./components',
|
||||
'./components/*',
|
||||
'./components/scenarios/settings/model-settings',
|
||||
'./components/scenes',
|
||||
'./composables',
|
||||
'./composables/*',
|
||||
'./constants',
|
||||
'./constants/*',
|
||||
'./libs',
|
||||
'./libs/*',
|
||||
'./stores',
|
||||
'./stores/*',
|
||||
'./stores/analytics',
|
||||
'./stores/analytics/posthog',
|
||||
'./stores/analytics/privacy-policy',
|
||||
'./stores/character',
|
||||
'./stores/modules/vision',
|
||||
'./stores/providers/aliyun',
|
||||
'./stores/settings',
|
||||
'./stores/settings/analytics',
|
||||
'./types',
|
||||
'./types/*',
|
||||
'./utils',
|
||||
'./utils/tts',
|
||||
'./workers',
|
||||
'./workers/*',
|
||||
'./workers/vad',
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps critical store and type mappings unchanged', () => {
|
||||
const exportsMap = readExports()
|
||||
|
||||
expect(exportsMap['./stores']).toBe('./src/stores/index.ts')
|
||||
expect(exportsMap['./stores/*']).toBe('./src/stores/*.ts')
|
||||
expect(exportsMap['./types']).toBe('./src/types/index.ts')
|
||||
expect(exportsMap['./types/*']).toBe('./src/types/*.ts')
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ChatProvider } from '@xsai-ext/providers/utils'
|
||||
import type { Message } from '@xsai/shared-chat'
|
||||
|
||||
import { isToolRelatedError } from './llm'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { isToolRelatedError, useLLM } from './llm'
|
||||
|
||||
const {
|
||||
streamTextMock,
|
||||
mcpMock,
|
||||
debugMock,
|
||||
createSparkCommandToolMock,
|
||||
} = vi.hoisted(() => ({
|
||||
streamTextMock: vi.fn(),
|
||||
mcpMock: vi.fn(async () => []),
|
||||
debugMock: vi.fn(async () => []),
|
||||
createSparkCommandToolMock: vi.fn(async () => ({
|
||||
name: 'spark',
|
||||
description: '',
|
||||
parameters: {},
|
||||
execute: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@xsai/model', () => ({
|
||||
listModels: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@xsai/stream-text', () => ({
|
||||
streamText: streamTextMock,
|
||||
}))
|
||||
|
||||
vi.mock('@xsai/shared-chat', () => ({
|
||||
stepCountAtLeast: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../tools', () => ({
|
||||
mcp: mcpMock,
|
||||
debug: debugMock,
|
||||
createSparkCommandTool: createSparkCommandToolMock,
|
||||
}))
|
||||
|
||||
const provider = {
|
||||
chat: () => ({
|
||||
baseURL: 'https://example.com/',
|
||||
}),
|
||||
} as unknown as ChatProvider
|
||||
|
||||
function createMockStreamResult() {
|
||||
return {
|
||||
steps: Promise.resolve([]),
|
||||
messages: Promise.resolve([]),
|
||||
usage: Promise.resolve({}),
|
||||
totalUsage: Promise.resolve({}),
|
||||
}
|
||||
}
|
||||
|
||||
describe('isToolRelatedError', () => {
|
||||
beforeEach(() => {
|
||||
streamTextMock.mockReset()
|
||||
mcpMock.mockClear()
|
||||
debugMock.mockClear()
|
||||
createSparkCommandToolMock.mockClear()
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
const positives: [provider: string, msg: string][] = [
|
||||
['ollama', 'llama3 does not support tools'],
|
||||
['ollama', 'phi does not support tools'],
|
||||
@@ -46,4 +108,87 @@ describe('isToolRelatedError', () => {
|
||||
expect(isToolRelatedError(new Error(msg))).toBe(false)
|
||||
})
|
||||
}
|
||||
|
||||
it('keeps stream pending on tool_calls finish when waitForTools is true', async () => {
|
||||
let onEvent: ((event: unknown) => Promise<void>) | undefined
|
||||
streamTextMock.mockImplementation((options: { onEvent: (event: unknown) => Promise<void> }) => {
|
||||
onEvent = options.onEvent
|
||||
return createMockStreamResult()
|
||||
})
|
||||
|
||||
const store = useLLM()
|
||||
const onStreamEvent = vi.fn()
|
||||
let resolved = false
|
||||
|
||||
const pending = store.stream('model-a', provider, [{ role: 'user', content: 'hello' }] as Message[], {
|
||||
waitForTools: true,
|
||||
onStreamEvent,
|
||||
}).then(() => {
|
||||
resolved = true
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(onEvent).toBeTypeOf('function'))
|
||||
await onEvent!({ type: 'finish', finishReason: 'tool_calls' })
|
||||
await Promise.resolve()
|
||||
expect(resolved).toBe(false)
|
||||
|
||||
await onEvent!({ type: 'finish', finishReason: 'stop' })
|
||||
await pending
|
||||
|
||||
expect(onStreamEvent).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('rejects stream on error event after waitForTools hold', async () => {
|
||||
let onEvent: ((event: unknown) => Promise<void>) | undefined
|
||||
streamTextMock.mockImplementation((options: { onEvent: (event: unknown) => Promise<void> }) => {
|
||||
onEvent = options.onEvent
|
||||
return createMockStreamResult()
|
||||
})
|
||||
|
||||
const store = useLLM()
|
||||
const pending = store.stream('model-a', provider, [{ role: 'user', content: 'hello' }] as Message[], {
|
||||
waitForTools: true,
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(onEvent).toBeTypeOf('function'))
|
||||
await onEvent!({ type: 'finish', finishReason: 'tool_calls' })
|
||||
await onEvent!({ type: 'error', error: new Error('stream failed') })
|
||||
await expect(pending).rejects.toThrow('stream failed')
|
||||
})
|
||||
|
||||
it('keeps builtin tools and auto-disables tools after tool-related errors', async () => {
|
||||
const store = useLLM()
|
||||
const customTool = { name: 'custom-tool' } as any
|
||||
|
||||
streamTextMock.mockImplementationOnce((options: { onEvent: (event: unknown) => Promise<void>, tools?: unknown[] }) => {
|
||||
queueMicrotask(async () => {
|
||||
await options.onEvent({ type: 'error', error: new Error('model does not support tools') })
|
||||
})
|
||||
return createMockStreamResult()
|
||||
})
|
||||
|
||||
await expect(store.stream('model-a', provider, [{ role: 'user', content: 'hello' }] as Message[], {
|
||||
tools: [customTool],
|
||||
})).rejects.toThrow('does not support tools')
|
||||
|
||||
const firstCallTools = streamTextMock.mock.calls[0]?.[0]?.tools
|
||||
expect(Array.isArray(firstCallTools)).toBe(true)
|
||||
expect(mcpMock).toHaveBeenCalledTimes(1)
|
||||
expect(debugMock).toHaveBeenCalledTimes(1)
|
||||
expect(firstCallTools).toContain(customTool)
|
||||
|
||||
streamTextMock.mockImplementationOnce((options: { onEvent: (event: unknown) => Promise<void>, tools?: unknown[] }) => {
|
||||
queueMicrotask(async () => {
|
||||
await options.onEvent({ type: 'finish', finishReason: 'stop' })
|
||||
})
|
||||
return createMockStreamResult()
|
||||
})
|
||||
|
||||
await store.stream('model-a', provider, [{ role: 'user', content: 'hello again' }] as Message[], {
|
||||
tools: [customTool],
|
||||
})
|
||||
|
||||
const secondCallTools = streamTextMock.mock.calls[1]?.[0]?.tools
|
||||
expect(secondCallTools).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import { useContextBridgeStore } from './context-bridge'
|
||||
|
||||
type HookCallback = (...args: any[]) => Promise<void> | void
|
||||
|
||||
const chatContextIngestMock = vi.fn()
|
||||
const beginStreamMock = vi.fn()
|
||||
const appendStreamLiteralMock = vi.fn()
|
||||
const finalizeStreamMock = vi.fn()
|
||||
const resetStreamMock = vi.fn()
|
||||
const serverSendMock = vi.fn()
|
||||
const ensureConnectedMock = vi.fn().mockResolvedValue(undefined)
|
||||
const onReconnectedMock = vi.fn(() => () => {})
|
||||
const onContextUpdateMock = vi.fn(() => () => {})
|
||||
const onEventMock = vi.fn(() => () => {})
|
||||
const getProviderInstanceMock = vi.fn()
|
||||
|
||||
const activeProviderRef = ref<string | null>(null)
|
||||
const activeModelRef = ref<string | null>(null)
|
||||
const incomingContextRef = ref<any>(null)
|
||||
const incomingStreamRef = ref<any>(null)
|
||||
const broadcastContextMock = vi.fn()
|
||||
const broadcastStreamMock = vi.fn()
|
||||
|
||||
const beforeComposeHooks: HookCallback[] = []
|
||||
const afterComposeHooks: HookCallback[] = []
|
||||
const beforeSendHooks: HookCallback[] = []
|
||||
const afterSendHooks: HookCallback[] = []
|
||||
const tokenLiteralHooks: HookCallback[] = []
|
||||
const tokenSpecialHooks: HookCallback[] = []
|
||||
const streamEndHooks: HookCallback[] = []
|
||||
const assistantEndHooks: HookCallback[] = []
|
||||
const assistantMessageHooks: HookCallback[] = []
|
||||
const turnCompleteHooks: HookCallback[] = []
|
||||
|
||||
const activeSessionIdRef = ref('session-1')
|
||||
let currentGeneration = 7
|
||||
|
||||
function registerHook(target: HookCallback[], callback: HookCallback) {
|
||||
target.push(callback)
|
||||
return () => {
|
||||
const index = target.indexOf(callback)
|
||||
if (index >= 0)
|
||||
target.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
async function emitHooks(target: HookCallback[], ...args: any[]) {
|
||||
for (const callback of target) {
|
||||
await callback(...args)
|
||||
}
|
||||
}
|
||||
|
||||
const chatOrchestratorMock = {
|
||||
sending: false,
|
||||
ingest: vi.fn(),
|
||||
|
||||
onBeforeMessageComposed: (callback: HookCallback) => registerHook(beforeComposeHooks, callback),
|
||||
onAfterMessageComposed: (callback: HookCallback) => registerHook(afterComposeHooks, callback),
|
||||
onBeforeSend: (callback: HookCallback) => registerHook(beforeSendHooks, callback),
|
||||
onAfterSend: (callback: HookCallback) => registerHook(afterSendHooks, callback),
|
||||
onTokenLiteral: (callback: HookCallback) => registerHook(tokenLiteralHooks, callback),
|
||||
onTokenSpecial: (callback: HookCallback) => registerHook(tokenSpecialHooks, callback),
|
||||
onStreamEnd: (callback: HookCallback) => registerHook(streamEndHooks, callback),
|
||||
onAssistantResponseEnd: (callback: HookCallback) => registerHook(assistantEndHooks, callback),
|
||||
onAssistantMessage: (callback: HookCallback) => registerHook(assistantMessageHooks, callback),
|
||||
onChatTurnComplete: (callback: HookCallback) => registerHook(turnCompleteHooks, callback),
|
||||
|
||||
emitBeforeMessageComposedHooks: (...args: any[]) => emitHooks(beforeComposeHooks, ...args),
|
||||
emitAfterMessageComposedHooks: (...args: any[]) => emitHooks(afterComposeHooks, ...args),
|
||||
emitBeforeSendHooks: (...args: any[]) => emitHooks(beforeSendHooks, ...args),
|
||||
emitAfterSendHooks: (...args: any[]) => emitHooks(afterSendHooks, ...args),
|
||||
emitTokenLiteralHooks: (...args: any[]) => emitHooks(tokenLiteralHooks, ...args),
|
||||
emitTokenSpecialHooks: (...args: any[]) => emitHooks(tokenSpecialHooks, ...args),
|
||||
emitStreamEndHooks: (...args: any[]) => emitHooks(streamEndHooks, ...args),
|
||||
emitAssistantResponseEndHooks: (...args: any[]) => emitHooks(assistantEndHooks, ...args),
|
||||
}
|
||||
|
||||
vi.mock('pinia', async () => {
|
||||
const actual = await vi.importActual<typeof import('pinia')>('pinia')
|
||||
return {
|
||||
...actual,
|
||||
storeToRefs: (store: any) => store,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@proj-airi/stage-shared', () => ({
|
||||
isStageWeb: () => true,
|
||||
isStageTamagotchi: () => false,
|
||||
}))
|
||||
|
||||
vi.mock('es-toolkit', () => ({
|
||||
Mutex: class {
|
||||
async acquire() {}
|
||||
release() {}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useBroadcastChannel: ({ name }: { name: string }) => {
|
||||
if (name === 'airi-context-update') {
|
||||
return {
|
||||
post: broadcastContextMock,
|
||||
data: incomingContextRef,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
post: broadcastStreamMock,
|
||||
data: incomingStreamRef,
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../chat', () => ({
|
||||
useChatOrchestratorStore: () => chatOrchestratorMock,
|
||||
}))
|
||||
|
||||
vi.mock('../../chat/context-store', () => ({
|
||||
useChatContextStore: () => ({
|
||||
ingestContextMessage: chatContextIngestMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../chat/session-store', () => ({
|
||||
useChatSessionStore: () => ({
|
||||
get activeSessionId() {
|
||||
return activeSessionIdRef.value
|
||||
},
|
||||
getSessionGenerationValue: () => currentGeneration,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../chat/stream-store', () => ({
|
||||
useChatStreamStore: () => ({
|
||||
beginStream: beginStreamMock,
|
||||
appendStreamLiteral: appendStreamLiteralMock,
|
||||
finalizeStream: finalizeStreamMock,
|
||||
resetStream: resetStreamMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../modules/consciousness', () => ({
|
||||
useConsciousnessStore: () => ({
|
||||
activeProvider: activeProviderRef,
|
||||
activeModel: activeModelRef,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../providers', () => ({
|
||||
useProvidersStore: () => ({
|
||||
getProviderInstance: getProviderInstanceMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('./channel-server', () => ({
|
||||
useModsServerChannelStore: () => ({
|
||||
ensureConnected: ensureConnectedMock,
|
||||
onReconnected: onReconnectedMock,
|
||||
onContextUpdate: onContextUpdateMock,
|
||||
onEvent: onEventMock,
|
||||
send: serverSendMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('context bridge contract', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
|
||||
chatContextIngestMock.mockReset()
|
||||
beginStreamMock.mockReset()
|
||||
appendStreamLiteralMock.mockReset()
|
||||
finalizeStreamMock.mockReset()
|
||||
resetStreamMock.mockReset()
|
||||
serverSendMock.mockReset()
|
||||
ensureConnectedMock.mockClear()
|
||||
ensureConnectedMock.mockResolvedValue(undefined)
|
||||
onReconnectedMock.mockClear()
|
||||
onContextUpdateMock.mockClear()
|
||||
onEventMock.mockClear()
|
||||
getProviderInstanceMock.mockReset()
|
||||
chatOrchestratorMock.ingest.mockReset()
|
||||
broadcastContextMock.mockReset()
|
||||
broadcastStreamMock.mockReset()
|
||||
|
||||
incomingContextRef.value = null
|
||||
incomingStreamRef.value = null
|
||||
activeProviderRef.value = null
|
||||
activeModelRef.value = null
|
||||
activeSessionIdRef.value = 'session-1'
|
||||
currentGeneration = 7
|
||||
chatOrchestratorMock.sending = false
|
||||
|
||||
beforeComposeHooks.length = 0
|
||||
afterComposeHooks.length = 0
|
||||
beforeSendHooks.length = 0
|
||||
afterSendHooks.length = 0
|
||||
tokenLiteralHooks.length = 0
|
||||
tokenSpecialHooks.length = 0
|
||||
streamEndHooks.length = 0
|
||||
assistantEndHooks.length = 0
|
||||
assistantMessageHooks.length = 0
|
||||
turnCompleteHooks.length = 0
|
||||
})
|
||||
|
||||
it('replays remote stream lifecycle into sending and stream store APIs', async () => {
|
||||
const store = useContextBridgeStore()
|
||||
await store.initialize()
|
||||
|
||||
const context = {
|
||||
message: { role: 'user', content: 'ping' },
|
||||
contexts: {},
|
||||
composedMessage: [],
|
||||
}
|
||||
|
||||
incomingStreamRef.value = { type: 'before-send', message: 'ping', sessionId: 'remote-session', context }
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(chatOrchestratorMock.sending).toBe(true)
|
||||
expect(beginStreamMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
incomingStreamRef.value = { type: 'token-literal', literal: 'hello', sessionId: 'remote-session', context }
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
expect(appendStreamLiteralMock).toHaveBeenCalledWith('hello')
|
||||
|
||||
incomingStreamRef.value = { type: 'assistant-end', message: 'final answer', sessionId: 'remote-session', context }
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
|
||||
// The bridge should call resetStream on follower tabs, not finalizeStream,
|
||||
// to avoid corrupting history by persisting a duplicate assistant message.
|
||||
expect(finalizeStreamMock).not.toHaveBeenCalled()
|
||||
expect(resetStreamMock).toHaveBeenCalledTimes(1)
|
||||
expect(chatOrchestratorMock.sending).toBe(false)
|
||||
|
||||
await store.dispose()
|
||||
})
|
||||
|
||||
it('suppresses outbound broadcast while processing remote stream events', async () => {
|
||||
const store = useContextBridgeStore()
|
||||
await store.initialize()
|
||||
|
||||
const context = {
|
||||
message: { role: 'user', content: 'ping' },
|
||||
contexts: {},
|
||||
composedMessage: [],
|
||||
}
|
||||
|
||||
await chatOrchestratorMock.emitTokenSpecialHooks('manual-special', context)
|
||||
expect(broadcastStreamMock).toHaveBeenCalledTimes(1)
|
||||
broadcastStreamMock.mockClear()
|
||||
|
||||
incomingStreamRef.value = { type: 'token-special', special: 'remote-special', sessionId: 'remote-session', context }
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(broadcastStreamMock).not.toHaveBeenCalled()
|
||||
|
||||
await store.dispose()
|
||||
})
|
||||
|
||||
it('ignores remote literal and end events when generation guard is stale', async () => {
|
||||
const store = useContextBridgeStore()
|
||||
await store.initialize()
|
||||
|
||||
const context = {
|
||||
message: { role: 'user', content: 'ping' },
|
||||
contexts: {},
|
||||
composedMessage: [],
|
||||
}
|
||||
|
||||
incomingStreamRef.value = { type: 'before-send', message: 'ping', sessionId: 'remote-session', context }
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
expect(beginStreamMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
currentGeneration = 8
|
||||
incomingStreamRef.value = { type: 'token-literal', literal: 'stale-literal', sessionId: 'remote-session', context }
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
|
||||
incomingStreamRef.value = { type: 'stream-end', sessionId: 'remote-session', context }
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(appendStreamLiteralMock).not.toHaveBeenCalledWith('stale-literal')
|
||||
expect(finalizeStreamMock).not.toHaveBeenCalled()
|
||||
|
||||
await store.dispose()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user