test(stage-ui): add contract test (#1487)

This commit is contained in:
Nashchennc
2026-04-11 00:49:13 +08:00
committed by GitHub
parent 9a59848b85
commit 933ef01b10
6 changed files with 892 additions and 4 deletions
@@ -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')
})
})
+147 -2
View File
@@ -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()
})
})