diff --git a/packages/stage-ui/src/composables/vision/use-vision-inference.test.ts b/packages/stage-ui/src/composables/vision/use-vision-inference.test.ts index 63b99d865..98e56bd57 100644 --- a/packages/stage-ui/src/composables/vision/use-vision-inference.test.ts +++ b/packages/stage-ui/src/composables/vision/use-vision-inference.test.ts @@ -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 }) diff --git a/packages/stage-ui/src/stores/chat.contract.test.ts b/packages/stage-ui/src/stores/chat.contract.test.ts new file mode 100644 index 000000000..b2e379569 --- /dev/null +++ b/packages/stage-ui/src/stores/chat.contract.test.ts @@ -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({ role: 'assistant', content: '', slices: [], tool_results: [] }) +const sessionMessages: Record = {} +let currentGeneration = 1 + +vi.mock('pinia', async () => { + const actual = await vi.importActual('pinia') + return { + ...actual, + storeToRefs: (store: any) => store, + } +}) + +vi.mock('@proj-airi/stream-kit', () => ({ + createQueue: ({ handlers }: { handlers: Array<(ctx: { data: any }) => Promise | 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, onEnd?: (fullText: string) => Promise }) => { + 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('') + expect(syntheticContextText).toContain('') + }) + + 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') + }) +}) diff --git a/packages/stage-ui/src/stores/chat/session-message-merge.test.ts b/packages/stage-ui/src/stores/chat/session-message-merge.test.ts index 15ef2bfae..c0c53057a 100644 --- a/packages/stage-ui/src/stores/chat/session-message-merge.test.ts +++ b/packages/stage-ui/src/stores/chat/session-message-merge.test.ts @@ -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) + }) }) diff --git a/packages/stage-ui/src/stores/exports.contract.test.ts b/packages/stage-ui/src/stores/exports.contract.test.ts new file mode 100644 index 000000000..e84ee0910 --- /dev/null +++ b/packages/stage-ui/src/stores/exports.contract.test.ts @@ -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 + } + 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') + }) +}) diff --git a/packages/stage-ui/src/stores/llm.test.ts b/packages/stage-ui/src/stores/llm.test.ts index 52a1116af..f23b51c4d 100644 --- a/packages/stage-ui/src/stores/llm.test.ts +++ b/packages/stage-ui/src/stores/llm.test.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) | undefined + streamTextMock.mockImplementation((options: { onEvent: (event: unknown) => Promise }) => { + 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) | undefined + streamTextMock.mockImplementation((options: { onEvent: (event: unknown) => Promise }) => { + 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, 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, 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() + }) }) diff --git a/packages/stage-ui/src/stores/mods/api/context-bridge.contract.test.ts b/packages/stage-ui/src/stores/mods/api/context-bridge.contract.test.ts new file mode 100644 index 000000000..976419f3b --- /dev/null +++ b/packages/stage-ui/src/stores/mods/api/context-bridge.contract.test.ts @@ -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 + +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(null) +const activeModelRef = ref(null) +const incomingContextRef = ref(null) +const incomingStreamRef = ref(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('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() + }) +})