fix(stage-tamagotchi): repair chat-list add, switch, and delete (#2086)

Co-authored-by: RainbowBird <git@luoling.moe>
This commit is contained in:
Columbina
2026-08-14 08:24:56 +00:00
committed by GitHub
co-authored by RainbowBird
parent cc791fab95
commit 96465c8130
24 changed files with 2379 additions and 623 deletions
+4 -2
View File
@@ -79,6 +79,7 @@ const builtinToolsStore = useTamagotchiBuiltinToolsStore()
const mcpToolsStore = useTamagotchiMcpToolsStore() const mcpToolsStore = useTamagotchiMcpToolsStore()
const pluginToolsStore = useTamagotchiPluginToolsStore() const pluginToolsStore = useTamagotchiPluginToolsStore()
const syncedPinia = usePiniaSynced() const syncedPinia = usePiniaSynced()
chatSessionStore.setCloudSyncOwnership(syncedPinia.isLeader())
const isSpotlightWindowRoute = initialWindowRoutePath === '/spotlight' const isSpotlightWindowRoute = initialWindowRoutePath === '/spotlight'
const isSettingsWindowRoute = initialWindowRoutePath === '/settings' || initialWindowRoutePath.startsWith('/settings/') const isSettingsWindowRoute = initialWindowRoutePath === '/settings' || initialWindowRoutePath.startsWith('/settings/')
const isEditorWindowRoute = initialWindowRoutePath === '/editor' const isEditorWindowRoute = initialWindowRoutePath === '/editor'
@@ -99,7 +100,8 @@ async function refreshPluginRuntimeTools() {
// Every renderer creates the runtime tool stores because every renderer can // Every renderer creates the runtime tool stores because every renderer can
// become the leader. Only the leader discovers tools and keeps executors. // become the leader. Only the leader discovers tools and keeps executors.
const stopToolLeadershipListener = syncedPinia.onLeadershipChange((isLeader) => { const stopLeadershipListener = syncedPinia.onLeadershipChange((isLeader) => {
chatSessionStore.setCloudSyncOwnership(isLeader)
if (!isLeader) if (!isLeader)
return return
@@ -313,7 +315,7 @@ watch(themeColorsHueDynamic, () => {
}, { immediate: true }) }, { immediate: true })
onUnmounted(() => { onUnmounted(() => {
stopToolLeadershipListener?.() stopLeadershipListener?.()
fullStageRuntime?.dispose() fullStageRuntime?.dispose()
}) })
</script> </script>
@@ -0,0 +1,320 @@
import type { ChatSessionMeta } from '@proj-airi/stage-ui/types/chat-session'
import type { Component } from 'vue'
import SharedInteractiveArea from '@proj-airi/stage-layouts/components/Layouts/InteractiveArea'
import MobileInteractiveArea from '@proj-airi/stage-layouts/components/Layouts/MobileInteractiveArea'
import ChatArea from '@proj-airi/stage-layouts/components/Widgets/ChatArea'
import { PiniaColada } from '@pinia/colada'
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
import { useChatStreamStore } from '@proj-airi/stage-ui/stores/chat/stream-store'
import { createPinia } from 'pinia'
import { describe, expect, it, vi } from 'vitest'
import { render } from 'vitest-browser-vue'
import { userEvent } from 'vitest/browser'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { createMemoryHistory, createRouter } from 'vue-router'
import InteractiveArea from './InteractiveArea.vue'
function createTestI18n() {
return createI18n({
legacy: false,
locale: 'en',
missingWarn: false,
fallbackWarn: false,
messages: { en: {} },
})
}
async function renderArea(component: Component = InteractiveArea) {
const sessionB: ChatSessionMeta = {
sessionId: 'session-b',
userId: 'local',
characterId: 'default',
createdAt: 1,
updatedAt: 1,
}
const sessionA: ChatSessionMeta = {
...sessionB,
sessionId: 'session-a',
createdAt: 2,
updatedAt: 2,
}
const pinia = createPinia()
pinia.state.value = {
'chat-session-selection': { activeSessionId: 'session-b' },
'chat-session': {
sessionMetas: { 'session-a': sessionA, 'session-b': sessionB },
sessionMessages: {
'session-a': [{ id: 'system-a', role: 'system', content: 'session A prompt' }],
'session-b': [{ id: 'system', role: 'system', content: 'system prompt' }],
},
},
}
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: { template: '<div />' } }],
})
await router.push('/')
await router.isReady()
const screen = await render(component, {
global: { plugins: [pinia, PiniaColada, createTestI18n(), router] },
})
return {
chat: useChatStore(pinia),
chatSession: useChatSessionStore(pinia),
chatStream: useChatStreamStore(pinia),
screen,
}
}
async function submitDraft(screen: Awaited<ReturnType<typeof renderArea>>['screen'], draft: string) {
const input = screen.getByRole('textbox')
await userEvent.fill(input, draft)
await userEvent.click(input)
await userEvent.keyboard('{Enter}')
return input
}
describe('interactive area synchronized state', () => {
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3743121861
it('renders the active synchronized stream through the real chat history for Issue #2085', async () => {
// ROOT CAUSE:
//
// A follower received the leader-owned active stream in the real chat
// store, but InteractiveArea passed its unrelated foreground stream to
// ChatHistory. Mocking either store or component hid that broken binding.
const { chat, chatStream, screen } = await renderArea()
chat.$patch({
activeSendSessionId: 'session-b',
activeStreamingMessage: {
id: 'follower-b-stream',
role: 'assistant',
content: 'Follower B live response',
slices: [{ type: 'text', text: 'Follower B live response' }],
tool_results: [],
createdAt: 2,
},
sending: true,
})
chatStream.$patch({
streamingMessage: {
id: 'leader-a-stream',
role: 'assistant',
content: 'Leader A foreground response',
slices: [{ type: 'text', text: 'Leader A foreground response' }],
tool_results: [],
createdAt: 3,
},
})
await nextTick()
await expect.element(screen.getByText('Follower B live response')).toBeVisible()
await expect.element(screen.getByText('Leader A foreground response')).not.toBeInTheDocument()
})
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3743309235
it('scopes the mobile synchronized stream to its local session for Issue #2085', async () => {
// ROOT CAUSE:
//
// MobileInteractiveArea passed the synchronized global sending state and
// foreground stream directly to ChatHistory. A mobile window on session B
// therefore rendered the live response from a send targeting session A.
const { chat, chatStream, screen } = await renderArea(MobileInteractiveArea)
chat.$patch({
activeSendSessionId: 'session-a',
activeStreamingMessage: {
id: 'session-a-stream',
role: 'assistant',
content: 'Session A live response',
slices: [{ type: 'text', text: 'Session A live response' }],
tool_results: [],
createdAt: 2,
},
sending: true,
})
chatStream.$patch({
streamingMessage: {
id: 'session-a-foreground',
role: 'assistant',
content: 'Session A live response',
slices: [{ type: 'text', text: 'Session A live response' }],
tool_results: [],
createdAt: 2,
},
})
await nextTick()
await expect.element(screen.getByText('Session A live response')).not.toBeInTheDocument()
chat.$patch({
activeSendSessionId: 'session-b',
activeStreamingMessage: {
id: 'session-b-stream',
role: 'assistant',
content: 'Session B live response',
slices: [{ type: 'text', text: 'Session B live response' }],
tool_results: [],
createdAt: 3,
},
})
await nextTick()
await expect.element(screen.getByText('Session B live response')).toBeVisible()
})
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3743366443
it('scopes the stage-web desktop synchronized stream to its local session for Issue #2085', async () => {
// ROOT CAUSE:
//
// The shared desktop layout derived sending from the target session but
// still passed the leader foreground stream to ChatHistory. A web window
// on B could therefore append A's live response.
const { chat, chatStream, screen } = await renderArea(SharedInteractiveArea)
chat.$patch({
activeSendSessionId: 'session-b',
activeStreamingMessage: {
id: 'session-b-web-stream',
role: 'assistant',
content: 'Session B web response',
slices: [{ type: 'text', text: 'Session B web response' }],
tool_results: [],
createdAt: 2,
},
sending: true,
})
chatStream.$patch({
streamingMessage: {
id: 'session-a-web-foreground',
role: 'assistant',
content: 'Session A foreground response',
slices: [{ type: 'text', text: 'Session A foreground response' }],
tool_results: [],
createdAt: 3,
},
})
await nextTick()
await expect.element(screen.getByText('Session B web response')).toBeVisible()
await expect.element(screen.getByText('Session A foreground response')).not.toBeInTheDocument()
})
it('routes a stage-web send through the synchronized chat action', async () => {
const { chat, screen } = await renderArea(SharedInteractiveArea)
const send = vi.spyOn(chat, 'send').mockResolvedValueOnce({ messages: [], sessionId: 'session-b' })
await submitDraft(screen, 'web follower message')
await vi.waitFor(() => expect(send).toHaveBeenCalledWith({
sessionId: 'session-b',
text: 'web follower message',
}))
})
it('routes a mobile send through the synchronized chat action', async () => {
const { chat, screen } = await renderArea(MobileInteractiveArea)
const send = vi.spyOn(chat, 'send').mockResolvedValueOnce({ messages: [], sessionId: 'session-b' })
await submitDraft(screen, 'mobile follower message')
await vi.waitFor(() => expect(send).toHaveBeenCalledWith({
sessionId: 'session-b',
text: 'mobile follower message',
}))
})
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3755530944
it('keeps a failed mobile draft out of a newly selected session for Issue #2085', async () => {
// ROOT CAUSE:
//
// Shared layouts restored a rejected send into their component-wide input
// without checking whether the window still displayed the target session.
const { chat, chatSession, screen } = await renderArea(MobileInteractiveArea)
let rejectSend: ((error: Error) => void) | undefined
vi.spyOn(chat, 'send').mockImplementationOnce(() => new Promise((_resolve, reject) => {
rejectSend = reject
}))
const input = await submitDraft(screen, 'mobile draft from B')
chatSession.activeSessionId = 'session-a'
rejectSend?.(new Error('send failed'))
await expect.element(input).toHaveValue('')
})
it('does not restore a deleted-session draft in the shared chat widget', async () => {
const { chat, screen } = await renderArea(ChatArea)
let rejectSend: ((error: Error) => void) | undefined
vi.spyOn(chat, 'send').mockImplementationOnce(() => new Promise((_resolve, reject) => {
rejectSend = reject
}))
const input = await submitDraft(screen, 'deleted web draft')
rejectSend?.(new Error('Chat session was removed before send completed'))
await expect.element(input).toHaveValue('')
})
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3628804992
it('does not restore a failed draft into a newly selected session for Issue #2085', async () => {
// ROOT CAUSE:
//
// Failure recovery used the reactive selection instead of the session
// captured by the send, so a late rejection could move a draft.
const { chat, chatSession, screen } = await renderArea()
let rejectSend: ((error: Error) => void) | undefined
vi.spyOn(chat, 'send').mockImplementationOnce(() => new Promise((_resolve, reject) => {
rejectSend = reject
}))
const input = await submitDraft(screen, 'send from B')
await expect.element(input).toHaveValue('')
chatSession.activeSessionId = 'session-a'
rejectSend?.(new Error('hydrate failed'))
await expect.element(input).toHaveValue('')
})
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3629004140
it('restores a failed draft when its captured session is still active for Issue #2085', async () => {
const { chat, screen } = await renderArea()
vi.spyOn(chat, 'send').mockRejectedValueOnce(new Error('send failed'))
const input = await submitDraft(screen, 'retry this draft')
await expect.element(input).toHaveValue('retry this draft')
})
it('keeps a newer draft when an earlier send fails', async () => {
// ROOT CAUSE:
//
// Failure recovery replaced the textarea unconditionally. Text entered
// while the request was pending was lost with its attachment previews.
const { chat, screen } = await renderArea()
let rejectSend: ((error: Error) => void) | undefined
vi.spyOn(chat, 'send').mockImplementationOnce(() => new Promise((_resolve, reject) => {
rejectSend = reject
}))
const input = await submitDraft(screen, 'first draft')
await userEvent.fill(input, 'newer draft')
rejectSend?.(new Error('send failed'))
await expect.element(input).toHaveValue('first draft\nnewer draft')
})
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3743366446
it('discards a queued draft when deletion cancels its send for Issue #2085', async () => {
const { chat, screen } = await renderArea()
let rejectSend: ((error: Error) => void) | undefined
vi.spyOn(chat, 'send').mockImplementationOnce(() => new Promise((_resolve, reject) => {
rejectSend = reject
}))
const input = await submitDraft(screen, 'discard this deleted draft')
rejectSend?.(new Error('Chat session was reset before send could start'))
await expect.element(input).toHaveValue('')
})
})
@@ -2,6 +2,7 @@
import type { ChatToolCallRendererRegistry } from '@proj-airi/stage-ui/components' import type { ChatToolCallRendererRegistry } from '@proj-airi/stage-ui/components'
import type { ChatHistoryItem } from '@proj-airi/stage-ui/types/chat' import type { ChatHistoryItem } from '@proj-airi/stage-ui/types/chat'
import { errorMessageFrom } from '@moeru/std'
import { useStopSpeakingButton } from '@proj-airi/stage-layouts/composables/useStopSpeakingButton' import { useStopSpeakingButton } from '@proj-airi/stage-layouts/composables/useStopSpeakingButton'
import { ChatHistory, JournalPreviewModal } from '@proj-airi/stage-ui/components' import { ChatHistory, JournalPreviewModal } from '@proj-airi/stage-ui/components'
import { useAnalytics } from '@proj-airi/stage-ui/composables/use-analytics' import { useAnalytics } from '@proj-airi/stage-ui/composables/use-analytics'
@@ -37,9 +38,9 @@ const backgroundStore = useBackgroundStore()
const journalPreviewStore = useJournalPreviewStore() const journalPreviewStore = useJournalPreviewStore()
const airiCardStore = useAiriCardStore() const airiCardStore = useAiriCardStore()
const { messages } = storeToRefs(chatSession) const { activeSessionId, messages } = storeToRefs(chatSession)
const { streamingMessage } = storeToRefs(chatStream) const { streamingMessage } = storeToRefs(chatStream)
const { sending } = storeToRefs(chatStore) const { activeSendSessionId, activeStreamingMessage, sending } = storeToRefs(chatStore)
const { activeCard, activeCardId } = storeToRefs(airiCardStore) const { activeCard, activeCardId } = storeToRefs(airiCardStore)
const { t } = useI18n() const { t } = useI18n()
const { openImagePreview } = journalPreviewStore const { openImagePreview } = journalPreviewStore
@@ -88,6 +89,9 @@ async function handleSend() {
const textToSend = messageInput.value const textToSend = messageInput.value
const attachmentsToSend = attachments.value.map(att => ({ ...att })) const attachmentsToSend = attachments.value.map(att => ({ ...att }))
// The active session can change while the cross-window request is pending.
// Keep one correlation key for both the send and its failure recovery.
const targetSessionId = chatSession.activeSessionId
// optimistic clear // optimistic clear
messageInput.value = '' messageInput.value = ''
@@ -95,7 +99,7 @@ async function handleSend() {
try { try {
await chatStore.send({ await chatStore.send({
sessionId: chatSession.activeSessionId, sessionId: targetSessionId,
text: textToSend, text: textToSend,
attachments: attachmentsToSend, attachments: attachmentsToSend,
tools: artistryToolReferences, tools: artistryToolReferences,
@@ -103,10 +107,21 @@ async function handleSend() {
attachmentsToSend.forEach(att => URL.revokeObjectURL(att.url)) attachmentsToSend.forEach(att => URL.revokeObjectURL(att.url))
} }
catch { catch (error) {
// restore on failure const errorMessage = errorMessageFrom(error) ?? String(error)
messageInput.value = textToSend const wasCancelledForDeletedSession
attachments.value = attachmentsToSend = errorMessage.includes('Chat session was reset before send could start')
|| errorMessage.includes('Chat session was removed before send completed')
if (!wasCancelledForDeletedSession && chatSession.activeSessionId === targetSessionId) {
const currentDraft = messageInput.value
messageInput.value = currentDraft ? `${textToSend}\n${currentDraft}` : textToSend
attachments.value = [...attachmentsToSend, ...attachments.value]
}
else {
// This window no longer owns a visible attachment preview, so its Blob
// URLs must be released instead of surviving until the window closes.
attachmentsToSend.forEach(attachment => URL.revokeObjectURL(attachment.url))
}
} }
} }
@@ -197,6 +212,10 @@ watch(sendMode, () => {
const historyMessages = computed(() => messages.value as unknown as ChatHistoryItem[]) const historyMessages = computed(() => messages.value as unknown as ChatHistoryItem[])
const assistantLabel = computed(() => activeCard.value?.name?.trim() || undefined) const assistantLabel = computed(() => activeCard.value?.name?.trim() || undefined)
const isActiveSessionSending = computed(() => sending.value && activeSendSessionId.value === activeSessionId.value)
const visibleStreamingMessage = computed(() => activeSendSessionId.value === activeSessionId.value
? activeStreamingMessage.value
: streamingMessage.value)
async function handleDeleteMessage(index: number) { async function handleDeleteMessage(index: number) {
const message = messages.value[index] const message = messages.value[index]
@@ -252,8 +271,8 @@ async function handleCleanupMessages() {
<ChatHistory <ChatHistory
:messages="historyMessages" :messages="historyMessages"
:assistant-label="assistantLabel" :assistant-label="assistantLabel"
:sending="sending" :sending="isActiveSessionSending"
:streaming-message="streamingMessage" :streaming-message="visibleStreamingMessage"
:tool-call-renderers="toolCallRenderers" :tool-call-renderers="toolCallRenderers"
@delete-message="handleDeleteMessage($event.index)" @delete-message="handleDeleteMessage($event.index)"
@retry-message="handleRetryMessage($event.index)" @retry-message="handleRetryMessage($event.index)"
+29 -4
View File
@@ -3,19 +3,44 @@ import { cwd } from 'node:process'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import Info from 'unplugin-info/vite' import Info from 'unplugin-info/vite'
import { playwright } from '@vitest/browser-playwright'
import { loadEnv } from 'vite' import { loadEnv } from 'vite'
import { defineConfig } from 'vitest/config' import { defineConfig } from 'vitest/config'
export default defineConfig({ export default defineConfig({
root: import.meta.dirname,
plugins: [ plugins: [
Info(), Info(),
vue(), vue(),
], ],
test: { test: {
env: loadEnv('test', cwd(), ''), env: loadEnv('test', cwd(), ''),
include: ['src/**/*.test.ts', 'scripts/**/*.test.ts'], projects: [
exclude: ['**/node_modules/**', '**/.git/**'], {
fileParallelism: false, extends: true,
maxWorkers: 1, test: {
name: 'node',
include: ['src/**/*.test.ts', 'scripts/**/*.test.ts'],
exclude: ['src/**/*.browser.test.ts', '**/node_modules/**', '**/.git/**'],
fileParallelism: false,
maxWorkers: 1,
},
},
{
extends: true,
test: {
name: 'browser',
include: ['src/**/*.browser.test.ts'],
exclude: ['**/node_modules/**', '**/.git/**'],
browser: {
enabled: true,
provider: playwright(),
instances: [
{ browser: 'chromium' },
],
},
},
},
],
}, },
}) })
@@ -0,0 +1,20 @@
import { cwd } from 'node:process'
import vue from '@vitejs/plugin-vue'
import Info from 'unplugin-info/vite'
import { loadEnv } from 'vite'
import { defineProject } from 'vitest/config'
export default defineProject({
root: import.meta.dirname,
plugins: [Info(), vue()],
test: {
name: 'stage-tamagotchi:node',
env: loadEnv('test', cwd(), ''),
include: ['src/**/*.test.ts', 'scripts/**/*.test.ts'],
exclude: ['src/**/*.browser.test.ts', '**/node_modules/**', '**/.git/**'],
fileParallelism: false,
maxWorkers: 1,
},
})
+5
View File
@@ -3,6 +3,7 @@ import { OnboardingDialog, OnboardingStepAnalyticsNotice, ToasterRoot } from '@p
import { useInferencePreload } from '@proj-airi/stage-ui/composables' import { useInferencePreload } from '@proj-airi/stage-ui/composables'
import { useAuthProviderSync } from '@proj-airi/stage-ui/composables/use-auth-provider-sync' import { useAuthProviderSync } from '@proj-airi/stage-ui/composables/use-auth-provider-sync'
import { initializeAnalytics, isAnalyticsAvailableInBuild } from '@proj-airi/stage-ui/libs/analytics' import { initializeAnalytics, isAnalyticsAvailableInBuild } from '@proj-airi/stage-ui/libs/analytics'
import { usePiniaSynced } from '@proj-airi/stage-ui/libs/pinia'
import { useCharacterOrchestratorStore } from '@proj-airi/stage-ui/stores/character' import { useCharacterOrchestratorStore } from '@proj-airi/stage-ui/stores/character'
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store' import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models' import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
@@ -33,6 +34,9 @@ const settingsStore = useSettings()
const settings = storeToRefs(settingsStore) const settings = storeToRefs(settingsStore)
const onboardingStore = useOnboardingStore() const onboardingStore = useOnboardingStore()
const chatSessionStore = useChatSessionStore() const chatSessionStore = useChatSessionStore()
const syncedPinia = usePiniaSynced()
chatSessionStore.setCloudSyncOwnership(syncedPinia.isLeader())
const stopLeadershipListener = syncedPinia.onLeadershipChange(isLeader => chatSessionStore.setCloudSyncOwnership(isLeader))
const serverChannelStore = useModsServerChannelStore() const serverChannelStore = useModsServerChannelStore()
const characterOrchestratorStore = useCharacterOrchestratorStore() const characterOrchestratorStore = useCharacterOrchestratorStore()
const settingsAudioDeviceStore = useSettingsAudioDevice() const settingsAudioDeviceStore = useSettingsAudioDevice()
@@ -105,6 +109,7 @@ onMounted(async () => {
}) })
onUnmounted(() => { onUnmounted(() => {
stopLeadershipListener()
contextBridgeStore.dispose() contextBridgeStore.dispose()
}) })
+2 -1
View File
@@ -34,7 +34,8 @@
"build:packages": "turbo run build -F=\"./packages/*\"", "build:packages": "turbo run build -F=\"./packages/*\"",
"build:engines": "turbo run build -F=\"./engines/*\"", "build:engines": "turbo run build -F=\"./engines/*\"",
"test": "vitest --coverage", "test": "vitest --coverage",
"test:run": "vitest run && pnpm run test-audio-pipelines-transcribe:run && pnpm run test-ui:run", "test:run": "vitest run && pnpm run test-stage-tamagotchi:run && pnpm run test-audio-pipelines-transcribe:run && pnpm run test-ui:run",
"test-stage-tamagotchi:run": "vitest run --config apps/stage-tamagotchi/vitest.config.ts --project browser",
"test-audio-pipelines-transcribe:run": "vitest run --config packages/audio-pipelines-transcribe/vitest.config.ts", "test-audio-pipelines-transcribe:run": "vitest run --config packages/audio-pipelines-transcribe/vitest.config.ts",
"test-ui:run": "vitest run --config packages/stage-ui/vitest.config.ts", "test-ui:run": "vitest run --config packages/stage-ui/vitest.config.ts",
"lint": "moeru-lint .", "lint": "moeru-lint .",
@@ -142,11 +142,6 @@ function createHarness() {
} }
} }
/**
* @example
* const runtime = createChatOrchestratorRuntime(deps)
* await runtime.ingest('hello', { model, chatProvider })
*/
describe('createChatOrchestratorRuntime', () => { describe('createChatOrchestratorRuntime', () => {
// ROOT CAUSE: // ROOT CAUSE:
// //
@@ -290,10 +285,6 @@ describe('createChatOrchestratorRuntime', () => {
}) })
}) })
/**
* @example
* Hook order and prompt composition stay compatible with the stage-ui facade.
*/
it('keeps hook order and appends context prompt to the latest user message', async () => { it('keeps hook order and appends context prompt to the latest user message', async () => {
const harness = createHarness() const harness = createHarness()
harness.contextSnapshot['system:weather'] = [ harness.contextSnapshot['system:weather'] = [
@@ -447,11 +438,6 @@ describe('createChatOrchestratorRuntime', () => {
expect(legacyUserMessage.createdAt).toBe(new Date(2026, 3, 25, 18, 47).getTime()) expect(legacyUserMessage.createdAt).toBe(new Date(2026, 3, 25, 18, 47).getTime())
}) })
/**
* @example
* deps.getSystemPromptSupplement() returns tool guidance.
* The runtime appends it to the existing provider system message.
*/
it('appends system prompt supplement to the provider system message', async () => { it('appends system prompt supplement to the provider system message', async () => {
const harness = createHarness() const harness = createHarness()
let composedMessages: Message[] = [] let composedMessages: Message[] = []
@@ -473,11 +459,6 @@ describe('createChatOrchestratorRuntime', () => {
}) })
}) })
/**
* @example
* A session has only user history.
* The runtime creates a provider system message for supplemental guidance.
*/
it('creates a system message when only a system prompt supplement is available', async () => { it('creates a system message when only a system prompt supplement is available', async () => {
const harness = createHarness() const harness = createHarness()
let composedMessages: Message[] = [] let composedMessages: Message[] = []
@@ -501,10 +482,6 @@ describe('createChatOrchestratorRuntime', () => {
expect(composedMessages[1]).toMatchObject({ role: 'user' }) expect(composedMessages[1]).toMatchObject({ role: 'user' })
}) })
/**
* @example
* Runtime telemetry callbacks expose client-visible latency milestones.
*/
it('emits telemetry milestones for a successful voice-backed message round', async () => { it('emits telemetry milestones for a successful voice-backed message round', async () => {
const harness = createHarness() const harness = createHarness()
harness.monotonicNow.set([100, 150, 250, 400, 460]) harness.monotonicNow.set([100, 150, 250, 400, 460])
@@ -626,10 +603,6 @@ describe('createChatOrchestratorRuntime', () => {
expect(harness.telemetry.messageRound).toHaveLength(2) expect(harness.telemetry.messageRound).toHaveLength(2)
}) })
/**
* @example
* await expect(runtime.ingest('hello', { model, chatProvider })).rejects.toThrow('provider rejected')
*/
it('emits chat activation failure telemetry without raw provider messages', async () => { it('emits chat activation failure telemetry without raw provider messages', async () => {
const harness = createHarness() const harness = createHarness()
harness.stream.mockRejectedValueOnce(new Error('provider rejected with sensitive details')) harness.stream.mockRejectedValueOnce(new Error('provider rejected with sensitive details'))
@@ -696,10 +669,6 @@ describe('createChatOrchestratorRuntime', () => {
]) ])
}) })
/**
* @example
* Cancelling a queued send rejects only pending work that has not started.
*/
it('rejects cancelled queued sends before they start', async () => { it('rejects cancelled queued sends before they start', async () => {
const harness = createHarness() const harness = createHarness()
let releaseFirstSend: (() => void) | undefined let releaseFirstSend: (() => void) | undefined
@@ -731,10 +700,57 @@ describe('createChatOrchestratorRuntime', () => {
await firstSend await firstSend
}) })
/** // https://github.com/moeru-ai/airi/pull/2086#discussion_r3714754876
* @example it('suppresses completion hooks when an active send session is deleted for Issue #2085', async () => {
* A queued send rejects if its captured session generation becomes stale. // ROOT CAUSE:
*/ //
// Generation checks protected message mutation during a stream, but the
// runtime still emitted completion hooks and success analytics after the
// provider returned for a deleted session.
const harness = createHarness()
const completionHook = vi.fn()
harness.runtime.hooks.onStreamEnd(completionHook)
harness.runtime.hooks.onAssistantResponseEnd(completionHook)
harness.runtime.hooks.onAfterSend(completionHook)
harness.runtime.hooks.onAssistantMessage(completionHook)
harness.runtime.hooks.onChatTurnComplete(completionHook)
let finishStream: (() => void) | undefined
harness.stream.mockImplementationOnce(async (_model, _chatProvider, _messages, options) => {
await new Promise<void>((resolve) => {
finishStream = resolve
})
options?.onUsage?.({
inputTokens: 1,
outputTokens: 1,
totalTokens: 2,
source: 'reported',
})
await options?.onStreamEvent?.({ type: 'text-delta', text: 'deleted reply' })
await options?.onStreamEvent?.({ type: 'finish', finishReason: 'stop' })
})
const pendingSend = harness.runtime.ingest('delete this chat', {
model: 'gpt-test',
chatProvider: provider,
})
await vi.waitFor(() => {
expect(harness.stream).toHaveBeenCalledTimes(1)
})
harness.generation.set(2)
finishStream?.()
await pendingSend
expect(completionHook).not.toHaveBeenCalled()
expect(harness.assistantAppended).toEqual([])
expect(harness.assistantTurns).toEqual([])
expect(harness.telemetry.assistantResponseRendered).toEqual([])
expect(harness.telemetry.llmGeneration).toEqual([])
expect(harness.telemetry.messageRound).toEqual([])
expect(harness.telemetry.chatActivationSucceeded).toEqual([])
})
it('rejects stale generation sends before they start', async () => { it('rejects stale generation sends before they start', async () => {
const harness = createHarness() const harness = createHarness()
let releaseFirstSend: (() => void) | undefined let releaseFirstSend: (() => void) | undefined
@@ -767,17 +783,14 @@ describe('createChatOrchestratorRuntime', () => {
expect(harness.stream).toHaveBeenCalledTimes(1) expect(harness.stream).toHaveBeenCalledTimes(1)
}) })
/**
* @example
* runtime.setSending(true)
* expect(runtime.getSending()).toBe(true)
*/
it('keeps sending externally writable for UI facades', () => { it('keeps sending externally writable for UI facades', () => {
const harness = createHarness() const harness = createHarness()
harness.runtime.setSending(true) harness.runtime.setSending(true)
expect(harness.runtime.getSending()).toBe(true) expect(harness.runtime.getSending()).toBe(true)
expect(harness.stateChanges.at(-1)).toEqual({ expect(harness.stateChanges.at(-1)).toEqual({
activeSendSessionId: 'session-1',
activeStreamingMessage: undefined,
sending: true, sending: true,
pendingQueuedSendCount: 0, pendingQueuedSendCount: 0,
}) })
@@ -785,16 +798,66 @@ describe('createChatOrchestratorRuntime', () => {
harness.runtime.setSending(false) harness.runtime.setSending(false)
expect(harness.runtime.getSending()).toBe(false) expect(harness.runtime.getSending()).toBe(false)
expect(harness.stateChanges.at(-1)).toEqual({ expect(harness.stateChanges.at(-1)).toEqual({
activeSendSessionId: undefined,
activeStreamingMessage: undefined,
sending: false,
pendingQueuedSendCount: 0,
})
})
// https://github.com/moeru-ai/airi/issues/2085
it('reports the queued send target while a background session is sending for Issue #2085', async () => {
// ROOT CAUSE:
//
// Runtime state exposed only a global sending boolean. A window-level sync
// layer therefore had to infer the owner from the authority's visible
// session, which is wrong when a follower targets a background session.
const harness = createHarness()
let finishSend: (() => void) | undefined
harness.stream.mockImplementationOnce(async (_model, _chatProvider, _messages, options) => {
await options?.onStreamEvent?.({ type: 'text-delta', text: 'background reply' })
await new Promise<void>((resolve) => {
finishSend = resolve
})
})
const pendingSend = harness.runtime.ingest('background request', {
model: 'gpt-test',
chatProvider: provider,
}, 'session-2')
await vi.waitFor(() => {
expect(harness.stateChanges).toContainEqual(expect.objectContaining({
activeSendSessionId: 'session-2',
activeStreamingMessage: expect.objectContaining({
role: 'assistant',
createdAt: expect.any(Number),
}),
sending: true,
pendingQueuedSendCount: 0,
}))
})
await vi.waitFor(() => {
expect(harness.stream).toHaveBeenCalledTimes(1)
})
await vi.waitFor(() => {
expect(harness.stateChanges).toContainEqual(expect.objectContaining({
activeSendSessionId: 'session-2',
activeStreamingMessage: expect.objectContaining({ content: expect.stringContaining('background') }),
}))
})
finishSend?.()
await pendingSend
expect(harness.stateChanges.at(-1)).toEqual({
activeSendSessionId: undefined,
activeStreamingMessage: undefined,
sending: false, sending: false,
pendingQueuedSendCount: 0, pendingQueuedSendCount: 0,
}) })
}) })
/**
* @example
* const snapshot = runtime.getPendingQueuedSendSnapshot()
* expect(snapshot[0].inputType).toBe('input:text')
*/
it('returns pending queued send snapshots with public fields', async () => { it('returns pending queued send snapshots with public fields', async () => {
const harness = createHarness() const harness = createHarness()
let releaseFirstSend: (() => void) | undefined let releaseFirstSend: (() => void) | undefined
@@ -852,10 +915,6 @@ describe('createChatOrchestratorRuntime', () => {
await firstSend await firstSend
}) })
/**
* @example
* Attachments, reasoning deltas, and tool events update the assistant builder.
*/
it('handles attachments, reasoning deltas, tool events, and assistant finalization', async () => { it('handles attachments, reasoning deltas, tool events, and assistant finalization', async () => {
const harness = createHarness() const harness = createHarness()
let composedMessages: Message[] = [] let composedMessages: Message[] = []
@@ -154,6 +154,10 @@ export interface ChatOrchestratorPromptProjection {
export interface ChatOrchestratorRuntimeState { export interface ChatOrchestratorRuntimeState {
/** Whether the runtime currently owns an active send. */ /** Whether the runtime currently owns an active send. */
sending: boolean sending: boolean
/** Session that owns the active send; undefined while the queue is idle. */
activeSendSessionId?: string
/** Latest assistant stream snapshot owned by the active send session. */
activeStreamingMessage?: StreamingAssistantMessage
/** Number of sends waiting behind the active one. */ /** Number of sends waiting behind the active one. */
pendingQueuedSendCount: number pendingQueuedSendCount: number
} }
@@ -350,19 +354,29 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
const unwrapMessage = deps.unwrapMessage ?? (<T>(message: T) => message) const unwrapMessage = deps.unwrapMessage ?? (<T>(message: T) => message)
let sending = false let sending = false
let activeSendSessionId: string | undefined
let activeStreamingMessage: StreamingAssistantMessage | undefined
let pendingQueuedSends: QueuedSend[] = [] let pendingQueuedSends: QueuedSend[] = []
function emitStateChange() { function emitStateChange() {
deps.onStateChange?.({ deps.onStateChange?.({
sending, sending,
activeSendSessionId,
activeStreamingMessage,
pendingQueuedSendCount: pendingQueuedSends.length, pendingQueuedSendCount: pendingQueuedSends.length,
}) })
} }
function setSending(next: boolean) { function setSending(next: boolean) {
if (sending === next) const nextActiveSendSessionId = next
? activeSendSessionId ?? deps.getActiveSessionId()
: undefined
if (sending === next && activeSendSessionId === nextActiveSendSessionId)
return return
sending = next sending = next
activeSendSessionId = nextActiveSendSessionId
if (!next)
activeStreamingMessage = undefined
emitStateChange() emitStateChange()
} }
@@ -370,7 +384,22 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
return sessionId === deps.getActiveSessionId() return sessionId === deps.getActiveSessionId()
} }
function patchForegroundStream(sessionId: string, message: StreamingAssistantMessage) { function beginStream(sessionId: string, message: StreamingAssistantMessage) {
sending = true
activeSendSessionId = sessionId
activeStreamingMessage = cloneStreamingMessage(message)
emitStateChange()
if (isForegroundSession(sessionId))
deps.foregroundStream.patch(cloneStreamingMessage(message))
}
function updateStream(sessionId: string, message: StreamingAssistantMessage) {
if (sessionId === activeSendSessionId) {
activeStreamingMessage = cloneStreamingMessage(message)
emitStateChange()
}
if (isForegroundSession(sessionId)) if (isForegroundSession(sessionId))
deps.foregroundStream.patch(cloneStreamingMessage(message)) deps.foregroundStream.patch(cloneStreamingMessage(message))
} }
@@ -481,8 +510,6 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
if (shouldAbort()) if (shouldAbort())
return return
setSending(true)
const buildingMessage: StreamingAssistantMessage = { const buildingMessage: StreamingAssistantMessage = {
role: 'assistant', role: 'assistant',
content: '', content: '',
@@ -491,7 +518,7 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
createdAt: now(), createdAt: now(),
id: assistantMessageId, id: assistantMessageId,
} }
patchForegroundStream(sessionId, buildingMessage) beginStream(sessionId, buildingMessage)
const sendSource = options.input ? 'voice' : 'text' const sendSource = options.input ? 'voice' : 'text'
const activeProvider = deps.getActiveProvider?.() ?? '' const activeProvider = deps.getActiveProvider?.() ?? ''
// The user message is the durable start of a round, so its ID also serves // The user message is the durable start of a round, so its ID also serves
@@ -604,7 +631,7 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
text: speechOnly, text: speechOnly,
}) })
} }
patchForegroundStream(sessionId, buildingMessage) updateStream(sessionId, buildingMessage)
} }
}, },
onSpecial: async (special) => { onSpecial: async (special) => {
@@ -624,7 +651,7 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
speech: finalCategorization.speech, speech: finalCategorization.speech,
reasoning: reasoningContentField || finalCategorization.reasoning, reasoning: reasoningContentField || finalCategorization.reasoning,
} }
patchForegroundStream(sessionId, buildingMessage) updateStream(sessionId, buildingMessage)
}, },
// The parser keeps its own marker-safety tail. Emit each safe literal // The parser keeps its own marker-safety tail. Emit each safe literal
// chunk so slow providers update the chat before they reach 24 characters. // chunk so slow providers update the chat before they reach 24 characters.
@@ -638,13 +665,13 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
return return
if (ctx.data.type === 'tool-call') { if (ctx.data.type === 'tool-call') {
buildingMessage.slices.push(ctx.data) buildingMessage.slices.push(ctx.data)
patchForegroundStream(sessionId, buildingMessage) updateStream(sessionId, buildingMessage)
return return
} }
if (ctx.data.type === 'tool-call-result') { if (ctx.data.type === 'tool-call-result') {
buildingMessage.tool_results.push(ctx.data) buildingMessage.tool_results.push(ctx.data)
patchForegroundStream(sessionId, buildingMessage) updateStream(sessionId, buildingMessage)
} }
}, },
], ],
@@ -749,6 +776,9 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
providerTranscript = structuredClone(currentTurnMessages) providerTranscript = structuredClone(currentTurnMessages)
}, },
onUsage: (usage) => { onUsage: (usage) => {
if (shouldAbort())
return
generationUsage = usage generationUsage = usage
deps.onLlmGeneration?.({ deps.onLlmGeneration?.({
...correlation, ...correlation,
@@ -761,6 +791,9 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
}) })
}, },
onStreamEvent: async (event: StreamEvent) => { onStreamEvent: async (event: StreamEvent) => {
if (shouldAbort())
return
switch (event.type) { switch (event.type) {
case 'tool-call': case 'tool-call':
toolCallQueue.enqueue({ toolCallQueue.enqueue({
@@ -812,7 +845,7 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
= Math.floor(nextReasoning.length / REASONING_UI_FLUSH_CHUNK_SIZE) = Math.floor(nextReasoning.length / REASONING_UI_FLUSH_CHUNK_SIZE)
> Math.floor(reasoning.length / REASONING_UI_FLUSH_CHUNK_SIZE) > Math.floor(reasoning.length / REASONING_UI_FLUSH_CHUNK_SIZE)
if (!reasoning || crossesBoundary) if (!reasoning || crossesBoundary)
patchForegroundStream(sessionId, buildingMessage) updateStream(sessionId, buildingMessage)
break break
} }
case 'finish': case 'finish':
@@ -823,7 +856,16 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
}, },
}) })
// Session generation is the lifecycle correlation key. Re-check it
// after every awaited completion boundary so deleting a session while a
// plugin hook runs cannot leak later hooks or success analytics.
if (shouldAbort())
return
await parser.end() await parser.end()
if (shouldAbort())
return
buildingMessage.providerTranscript = providerTranscript buildingMessage.providerTranscript = providerTranscript
deps.onAssistantResponseRendered?.({ deps.onAssistantResponseRendered?.({
...correlation, ...correlation,
@@ -841,17 +883,29 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
}) })
} }
if (shouldAbort())
return
await hooks.emitStreamEndHooks(streamingMessageContext) await hooks.emitStreamEndHooks(streamingMessageContext)
if (shouldAbort())
return
await hooks.emitAssistantResponseEndHooks(fullText, streamingMessageContext) await hooks.emitAssistantResponseEndHooks(fullText, streamingMessageContext)
if (shouldAbort())
return
await hooks.emitAfterSendHooks(sendingMessage, streamingMessageContext) await hooks.emitAfterSendHooks(sendingMessage, streamingMessageContext)
if (shouldAbort())
return
await hooks.emitAssistantMessageHooks({ ...buildingMessage }, fullText, streamingMessageContext) await hooks.emitAssistantMessageHooks({ ...buildingMessage }, fullText, streamingMessageContext)
if (shouldAbort())
return
await hooks.emitChatTurnCompleteHooks({ await hooks.emitChatTurnCompleteHooks({
output: { ...buildingMessage }, output: { ...buildingMessage },
outputText: fullText, outputText: fullText,
toolCalls: sessionMessagesForSend.filter(msg => msg.role === 'tool') as ToolMessage[], toolCalls: sessionMessagesForSend.filter(msg => msg.role === 'tool') as ToolMessage[],
}, streamingMessageContext) }, streamingMessageContext)
if (shouldAbort())
return
deps.onAssistantTurnReady?.({ deps.onAssistantTurnReady?.({
messageText: fullText, messageText: fullText,
sessionMessages: sessionMessagesForSend, sessionMessages: sessionMessagesForSend,
@@ -880,6 +934,9 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
} }
} }
catch (error) { catch (error) {
if (isStaleGeneration())
return
console.error('Error sending message:', error) console.error('Error sending message:', error)
deps.onMessageRoundFailed?.({ deps.onMessageRoundFailed?.({
...correlation, ...correlation,
@@ -6,6 +6,7 @@ import { useAnalytics } from '@proj-airi/stage-ui/composables/use-analytics'
import { useChatStore } from '@proj-airi/stage-ui/stores/chat' import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store' import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
import { useChatStreamStore } from '@proj-airi/stage-ui/stores/chat/stream-store' import { useChatStreamStore } from '@proj-airi/stage-ui/stores/chat/stream-store'
import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/context-bridge'
import { useDeferredMount } from '@proj-airi/ui' import { useDeferredMount } from '@proj-airi/ui'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
@@ -17,18 +18,30 @@ import ChatContainer from '../Widgets/ChatContainer.vue'
import { useChatToolCallRerun } from '../../composables/useChatToolCallRerun' import { useChatToolCallRerun } from '../../composables/useChatToolCallRerun'
const { isReady } = useDeferredMount() const { isReady } = useDeferredMount()
const { sending } = storeToRefs(useChatStore()) const { activeSendSessionId, activeStreamingMessage, sending } = storeToRefs(useChatStore())
const { messages } = storeToRefs(useChatSessionStore()) const { activeSessionId, messages } = storeToRefs(useChatSessionStore())
const { streamingMessage } = storeToRefs(useChatStreamStore()) const { streamingMessage } = storeToRefs(useChatStreamStore())
const { isReceivingRemoteStream } = storeToRefs(useContextBridgeStore())
const isLoading = ref(true) const isLoading = ref(true)
const historyMessages = computed(() => messages.value as unknown as ChatHistoryItem[]) const historyMessages = computed(() => messages.value as unknown as ChatHistoryItem[])
const isActiveSessionSending = computed(() => (
(sending.value && activeSendSessionId.value === activeSessionId.value)
|| isReceivingRemoteStream.value
))
const visibleStreamingMessage = computed(() => activeSendSessionId.value === activeSessionId.value
? activeStreamingMessage.value
: streamingMessage.value)
const { trackChatMessageDeleted } = useAnalytics() const { trackChatMessageDeleted } = useAnalytics()
const { rerunToolCall } = useChatToolCallRerun() const { rerunToolCall } = useChatToolCallRerun()
function handleDeleteMessage(index: number) { async function handleDeleteMessage(index: number) {
const message = messages.value[index] const message = messages.value[index]
messages.value = messages.value.filter((_, messageIndex) => messageIndex !== index) await useChatSessionStore().deleteMessage({
sessionId: activeSessionId.value,
messageId: message?.id,
index,
})
trackChatMessageDeleted({ trackChatMessageDeleted({
source: 'history', source: 'history',
message_role: message?.role ?? 'unknown', message_role: message?.role ?? 'unknown',
@@ -51,8 +64,8 @@ function handleDeleteMessage(index: number) {
<ChatHistory <ChatHistory
v-if="isReady" v-if="isReady"
:messages="historyMessages" :messages="historyMessages"
:sending="sending" :sending="isActiveSessionSending"
:streaming-message="streamingMessage" :streaming-message="visibleStreamingMessage"
h-full h-full
variant="desktop" variant="desktop"
@delete-message="handleDeleteMessage($event.index)" @delete-message="handleDeleteMessage($event.index)"
@@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ChatHistoryItem } from '@proj-airi/stage-ui/types/chat' import type { ChatHistoryItem } from '@proj-airi/stage-ui/types/chat'
import type { ChatProvider } from '@xsai-ext/providers/utils'
import { errorMessageFrom } from '@moeru/std' import { errorMessageFrom } from '@moeru/std'
import { isStageTamagotchi } from '@proj-airi/stage-shared' import { isStageTamagotchi } from '@proj-airi/stage-shared'
@@ -14,9 +13,7 @@ import { useChatMaintenanceStore } from '@proj-airi/stage-ui/stores/chat/mainten
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store' import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
import { useChatStreamStore } from '@proj-airi/stage-ui/stores/chat/stream-store' import { useChatStreamStore } from '@proj-airi/stage-ui/stores/chat/stream-store'
import { useL2dViewControl } from '@proj-airi/stage-ui/stores/live2d' import { useL2dViewControl } from '@proj-airi/stage-ui/stores/live2d'
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness' import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/context-bridge'
import { useProviderConfigStore } from '@proj-airi/stage-ui/stores/providers/config'
import { useProviderStore } from '@proj-airi/stage-ui/stores/providers/provider'
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings' import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { BasicTextarea, useTheme } from '@proj-airi/ui' import { BasicTextarea, useTheme } from '@proj-airi/ui'
import { useResizeObserver, useScreenSafeArea } from '@vueuse/core' import { useResizeObserver, useScreenSafeArea } from '@vueuse/core'
@@ -39,16 +36,28 @@ const chatOrchestrator = useChatStore()
const chatSession = useChatSessionStore() const chatSession = useChatSessionStore()
const chatStream = useChatStreamStore() const chatStream = useChatStreamStore()
const { cleanupMessages } = useChatMaintenanceStore() const { cleanupMessages } = useChatMaintenanceStore()
const { messages } = storeToRefs(chatSession) const { activeSessionId, messages } = storeToRefs(chatSession)
const { streamingMessage } = storeToRefs(chatStream) const { streamingMessage } = storeToRefs(chatStream)
const { sending } = storeToRefs(chatOrchestrator) const { activeSendSessionId, activeStreamingMessage, sending } = storeToRefs(chatOrchestrator)
const { isReceivingRemoteStream } = storeToRefs(useContextBridgeStore())
const historyMessages = computed(() => messages.value as unknown as ChatHistoryItem[]) const historyMessages = computed(() => messages.value as unknown as ChatHistoryItem[])
const isActiveSessionSending = computed(() => (
(sending.value && activeSendSessionId.value === activeSessionId.value)
|| isReceivingRemoteStream.value
))
const visibleStreamingMessage = computed(() => activeSendSessionId.value === activeSessionId.value
? activeStreamingMessage.value
: streamingMessage.value)
const { trackChatMessageDeleted, trackChatMessagesCleared } = useAnalytics() const { trackChatMessageDeleted, trackChatMessagesCleared } = useAnalytics()
const { rerunToolCall } = useChatToolCallRerun() const { rerunToolCall } = useChatToolCallRerun()
function handleDeleteMessage(index: number) { async function handleDeleteMessage(index: number) {
const message = messages.value[index] const message = messages.value[index]
messages.value = messages.value.filter((_, messageIndex) => messageIndex !== index) await chatSession.deleteMessage({
sessionId: activeSessionId.value,
messageId: message?.id,
index,
})
trackChatMessageDeleted({ trackChatMessageDeleted({
source: 'history', source: 'history',
message_role: message?.role ?? 'unknown', message_role: message?.role ?? 'unknown',
@@ -70,17 +79,12 @@ const backgroundDialogOpen = ref(false)
const sessionsDrawerOpen = ref(false) const sessionsDrawerOpen = ref(false)
const screenSafeArea = useScreenSafeArea() const screenSafeArea = useScreenSafeArea()
const providersStore = useProviderStore()
const providerStore = useProviderConfigStore()
const { activeProvider, activeModel } = storeToRefs(useConsciousnessStore())
useResizeObserver(document.documentElement, () => screenSafeArea.update()) useResizeObserver(document.documentElement, () => screenSafeArea.update())
const { themeColorsHueDynamic } = storeToRefs(useSettings()) const { themeColorsHueDynamic } = storeToRefs(useSettings())
const { viewControlsEnabled: l2dViewCtrlEnabled } = useL2dViewControl() const { viewControlsEnabled: l2dViewCtrlEnabled } = useL2dViewControl()
const { viewControlsEnabled: threeViewCtrlEnabled } = useThreeViewControl() const { viewControlsEnabled: threeViewCtrlEnabled } = useThreeViewControl()
const settingsAudioDevice = useSettingsAudioDevice() const settingsAudioDevice = useSettingsAudioDevice()
const { enabled, stream } = storeToRefs(settingsAudioDevice) const { enabled, stream } = storeToRefs(settingsAudioDevice)
const { ingest, onAfterMessageComposed } = chatOrchestrator
const { t } = useI18n() const { t } = useI18n()
const { audioContext } = useAudioContext() const { audioContext } = useAudioContext()
const { startAnalyzer, stopAnalyzer } = useAudioAnalyzer() const { startAnalyzer, stopAnalyzer } = useAudioAnalyzer()
@@ -112,23 +116,24 @@ async function handleSend() {
} }
const textToSend = messageInput.value const textToSend = messageInput.value
const targetSessionId = chatSession.activeSessionId
messageInput.value = '' messageInput.value = ''
try { try {
const providerConfig = providerStore.getProviderConfig(activeProvider.value) await chatOrchestrator.send({
sessionId: targetSessionId,
await ingest(textToSend, { text: textToSend,
chatProvider: await providersStore.getProviderInstance(activeProvider.value) as ChatProvider,
model: activeModel.value,
providerConfig,
}) })
} }
catch (error) { catch (error) {
messageInput.value = textToSend const errorMessage = errorMessageFrom(error) ?? String(error)
chatSession.appendSessionMessage(chatSession.activeSessionId, { const wasCancelledForDeletedSession
role: 'error', = errorMessage.includes('Chat session was reset before send could start')
content: errorMessageFrom(error) ?? 'Failed to send message', || errorMessage.includes('Chat session was removed before send completed')
}) if (!wasCancelledForDeletedSession && chatSession.activeSessionId === targetSessionId) {
const currentDraft = messageInput.value
messageInput.value = currentDraft ? `${textToSend}\n${currentDraft}` : textToSend
}
} }
} }
@@ -158,9 +163,6 @@ watch([enabled, stream], () => {
setupAnalyzer() setupAnalyzer()
}, { immediate: true }) }, { immediate: true })
onAfterMessageComposed(async () => {
})
onUnmounted(() => { onUnmounted(() => {
teardownAnalyzer() teardownAnalyzer()
}) })
@@ -179,8 +181,8 @@ onMounted(() => {
v-if="!threeViewCtrlEnabled && !l2dViewCtrlEnabled" v-if="!threeViewCtrlEnabled && !l2dViewCtrlEnabled"
variant="mobile" variant="mobile"
:messages="historyMessages" :messages="historyMessages"
:sending="sending" :sending="isActiveSessionSending"
:streaming-message="streamingMessage" :streaming-message="visibleStreamingMessage"
max-w="[calc(100%-3.5rem)]" max-w="[calc(100%-3.5rem)]"
w-full self-start pb-3 pl-3 w-full self-start pb-3 pl-3
class="chat-history" class="chat-history"
@@ -1,6 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ChatProvider } from '@xsai-ext/providers/utils'
import { errorMessageFrom } from '@moeru/std' import { errorMessageFrom } from '@moeru/std'
import { isStageTamagotchi } from '@proj-airi/stage-shared' import { isStageTamagotchi } from '@proj-airi/stage-shared'
import { HearingConfig } from '@proj-airi/stage-ui/components/scenarios/dialogs/audio-input/index' import { HearingConfig } from '@proj-airi/stage-ui/components/scenarios/dialogs/audio-input/index'
@@ -8,9 +6,6 @@ import { useAudioAnalyzer } from '@proj-airi/stage-ui/composables'
import { useAudioContext } from '@proj-airi/stage-ui/stores/audio' import { useAudioContext } from '@proj-airi/stage-ui/stores/audio'
import { useChatStore } from '@proj-airi/stage-ui/stores/chat' import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store' import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
import { useProviderConfigStore } from '@proj-airi/stage-ui/stores/providers/config'
import { useProviderStore } from '@proj-airi/stage-ui/stores/providers/provider'
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings' import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { BasicTextarea } from '@proj-airi/ui' import { BasicTextarea } from '@proj-airi/ui'
import { useLocalStorage } from '@vueuse/core' import { useLocalStorage } from '@vueuse/core'
@@ -34,17 +29,12 @@ type SendMode = (typeof SEND_MODES)[number]
const sendMode = useLocalStorage<SendMode>('ui/chat/settings/send-mode', 'enter') const sendMode = useLocalStorage<SendMode>('ui/chat/settings/send-mode', 'enter')
const lastEnterTime = ref(0) const lastEnterTime = ref(0)
const providersStore = useProviderStore()
const providerStore = useProviderConfigStore()
const { activeProvider, activeModel } = storeToRefs(useConsciousnessStore())
const { themeColorsHueDynamic } = storeToRefs(useSettings()) const { themeColorsHueDynamic } = storeToRefs(useSettings())
const { askPermission } = useSettingsAudioDevice() const { askPermission } = useSettingsAudioDevice()
const { enabled, stream } = storeToRefs(useSettingsAudioDevice()) const { enabled, stream } = storeToRefs(useSettingsAudioDevice())
const chatOrchestrator = useChatStore() const chatOrchestrator = useChatStore()
const chatSession = useChatSessionStore() const chatSession = useChatSessionStore()
const { ingest, onAfterMessageComposed } = chatOrchestrator
const { audioContext } = useAudioContext() const { audioContext } = useAudioContext()
const { t } = useI18n() const { t } = useI18n()
const sendModeLabels = computed<Record<SendMode, string>>(() => ({ const sendModeLabels = computed<Record<SendMode, string>>(() => ({
@@ -68,24 +58,24 @@ async function handleSend() {
} }
const textToSend = messageInput.value const textToSend = messageInput.value
const targetSessionId = chatSession.activeSessionId
messageInput.value = '' messageInput.value = ''
try { try {
const providerConfig = providerStore.getProviderConfig(activeProvider.value) await chatOrchestrator.send({
sessionId: targetSessionId,
await ingest(textToSend, { text: textToSend,
chatProvider: await providersStore.getProviderInstance(activeProvider.value) as ChatProvider,
model: activeModel.value,
providerConfig,
}) })
} }
catch (error) { catch (error) {
// preserve any user input when failed to send the message const errorMessage = errorMessageFrom(error) ?? String(error)
messageInput.value = [textToSend, messageInput.value.trim()].filter(Boolean).join(' ') const wasCancelledForDeletedSession
chatSession.appendSessionMessage(chatSession.activeSessionId, { = errorMessage.includes('Chat session was reset before send could start')
role: 'error', || errorMessage.includes('Chat session was removed before send completed')
content: errorMessageFrom(error) ?? 'Failed to send message', if (!wasCancelledForDeletedSession && chatSession.activeSessionId === targetSessionId) {
}) const currentDraft = messageInput.value
messageInput.value = currentDraft ? `${textToSend}\n${currentDraft}` : textToSend
}
} }
} }
@@ -135,9 +125,6 @@ watch(hearingPopoverOpen, async (value) => {
} }
}) })
onAfterMessageComposed(async () => {
})
const { startAnalyzer, stopAnalyzer } = useAudioAnalyzer() const { startAnalyzer, stopAnalyzer } = useAudioAnalyzer()
let analyzerSource: MediaStreamAudioSourceNode | undefined let analyzerSource: MediaStreamAudioSourceNode | undefined
@@ -0,0 +1,104 @@
import type { ChatSessionMeta } from '../../../../types/chat-session'
import { describe, expect, it } from 'vitest'
import { render } from 'vitest-browser-vue'
import { defineComponent, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import SessionsDialog from './sessions-dialog.vue'
function createTestI18n() {
return createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
stage: {
chat: {
sessions: {
'title': 'Chats',
'new': 'New chat',
'empty': 'No chats',
'delete': 'Delete',
'cloud-badge': 'Cloud synced',
},
},
},
},
},
})
}
function sessionMeta(sessionId: string, updatedAt: number): ChatSessionMeta {
return {
sessionId,
characterId: 'default',
userId: 'local',
createdAt: updatedAt,
updatedAt,
}
}
function createHarness() {
return defineComponent({
name: 'SessionsDialogHarness',
components: { SessionsDialog },
setup() {
const created = ref(0)
const selected = ref('none')
const deleted = ref('none')
return {
created,
deleted,
selected,
rows: [
{ meta: sessionMeta('session-one', 2), preview: 'First chat', isActive: true, updatedAtLabel: 'now' },
{ meta: sessionMeta('session-two', 1), preview: 'Second chat', isActive: false, updatedAtLabel: 'yesterday' },
],
}
},
template: `
<SessionsDialog
:open="true"
:rows="rows"
:is-desktop="false"
:is-creating-session="false"
mobile-padding-bottom="24px"
@new-session="created += 1"
@select-session="selected = $event"
@delete-session="deleted = $event"
/>
<output aria-label="created-session-count">{{ created }}</output>
<output aria-label="selected-session-id">{{ selected }}</output>
<output aria-label="deleted-session-id">{{ deleted }}</output>
`,
})
}
describe('sessions dialog actions', () => {
// https://github.com/moeru-ai/airi/issues/2085
it('keeps add, switch, and delete actions independent for Issue #2085', async () => {
// ROOT CAUSE:
//
// Vaul handled every pointer release on DrawerContent, including releases
// from its action buttons, and unmounted the sheet before `click` ran.
// The replacement uses a Reka dialog surface whose buttons emit one action
// each without a competing gesture-release lifecycle.
const screen = await render(createHarness(), {
global: {
plugins: [createTestI18n()],
},
})
await screen.getByRole('button', { name: 'New chat' }).click()
await expect.element(screen.getByLabelText('created-session-count')).toHaveTextContent('1')
await screen.getByRole('button', { name: 'Delete: Second chat' }).click()
await expect.element(screen.getByLabelText('deleted-session-id')).toHaveTextContent('session-two')
await expect.element(screen.getByLabelText('selected-session-id')).toHaveTextContent('none')
await screen.getByRole('button', { name: 'First chat now' }).click()
await expect.element(screen.getByLabelText('selected-session-id')).toHaveTextContent('session-one')
})
})
@@ -0,0 +1,132 @@
<script setup lang="ts">
import type { ChatSessionMeta } from '../../../../types/chat-session'
import { DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import { useI18n } from 'vue-i18n'
interface SessionRow {
meta: ChatSessionMeta
preview: string
isActive: boolean
updatedAtLabel: string
}
interface Props {
open: boolean
rows: SessionRow[]
isDesktop: boolean
isCreatingSession: boolean
mobilePaddingBottom: string
}
defineProps<Props>()
const emit = defineEmits<{
'deleteSession': [sessionId: string]
'newSession': []
'selectSession': [sessionId: string]
'update:open': [open: boolean]
}>()
const { t } = useI18n()
</script>
<template>
<DialogRoot :open="open" @update:open="value => emit('update:open', value)">
<slot name="trigger" />
<DialogPortal>
<DialogOverlay
:class="[
'fixed inset-0 z-[9999] bg-black/50 backdrop-blur-sm',
'data-[state=closed]:animate-fadeOut data-[state=open]:animate-fadeIn',
]"
/>
<DialogContent
:aria-describedby="undefined"
:class="[
'fixed z-[9999] flex flex-col overflow-hidden bg-white/95 shadow-xl outline-none backdrop-blur-md dark:bg-neutral-900/95',
isDesktop
? 'left-1/2 top-1/2 max-h-[80dvh] max-w-md w-[92dvw] rounded-2xl -translate-x-1/2 -translate-y-1/2 data-[state=closed]:animate-contentHide data-[state=open]:animate-contentShow'
: 'bottom-0 left-0 right-0 max-h-[85dvh] rounded-t-[32px]',
]"
:style="isDesktop ? undefined : { paddingBottom: mobilePaddingBottom }"
>
<div
v-if="!isDesktop"
:class="['mx-auto mt-3 h-1.5 w-12 shrink-0 rounded-full bg-neutral-400 dark:bg-neutral-600']"
aria-hidden="true"
/>
<div :class="['flex min-h-0 flex-1 flex-col']">
<div :class="['flex items-center justify-between px-5 pt-5 pb-3']">
<DialogTitle :class="['text-base font-medium text-neutral-700 dark:text-neutral-200']">
{{ t('stage.chat.sessions.title') }}
</DialogTitle>
<button
type="button"
:class="[
'rounded-lg px-3 py-1.5 text-xs font-medium',
'bg-primary-100/60 text-primary-700 dark:bg-primary-900/40 dark:text-primary-200',
'hover:bg-primary-200/70 dark:hover:bg-primary-800/50',
'transition-colors',
]"
:disabled="isCreatingSession"
@click="emit('newSession')"
>
{{ t('stage.chat.sessions.new') }}
</button>
</div>
<div :class="['flex-1 overflow-y-auto px-2 pb-4']">
<div v-if="rows.length === 0" :class="['p-6 text-center text-sm text-neutral-500 dark:text-neutral-400']">
{{ t('stage.chat.sessions.empty') }}
</div>
<div
v-for="row in rows"
:key="row.meta.sessionId"
:class="[
'group relative mb-1 w-full rounded-xl transition-colors',
row.isActive
? 'bg-primary-100/70 dark:bg-primary-900/40'
: 'hover:bg-neutral-100/80 dark:hover:bg-neutral-800/60',
]"
>
<button
type="button"
:class="['w-full flex flex-col gap-1 px-3 py-3 text-left outline-none']"
@click="emit('selectSession', row.meta.sessionId)"
>
<div :class="['flex items-center gap-2 text-sm font-medium text-neutral-700 dark:text-neutral-200']">
<span :class="['flex-1 truncate']">{{ row.preview }}</span>
<span
v-if="row.meta.cloudChatId"
:class="['shrink-0 rounded px-1.5 py-0.5 text-[10px] uppercase tracking-wide', 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300']"
:title="t('stage.chat.sessions.cloud-badge')"
>
cloud
</span>
<span :class="['w-7']" />
</div>
<div :class="['text-[11px] text-neutral-500 dark:text-neutral-400']">
{{ row.updatedAtLabel }}
</div>
</button>
<button
type="button"
:class="[
'absolute right-2 top-2 z-10 h-7 w-7 flex items-center justify-center rounded-md',
'opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100',
'text-neutral-400 hover:bg-red-500/10 hover:text-red-500',
'transition-opacity duration-150',
]"
:aria-label="`${t('stage.chat.sessions.delete')}: ${row.preview}`"
:title="t('stage.chat.sessions.delete')"
@click.stop="emit('deleteSession', row.meta.sessionId)"
>
<div class="i-solar:trash-bin-trash-bold-duotone h-4 w-4" />
</button>
</div>
</div>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
@@ -0,0 +1,149 @@
import type { ChatSessionMeta } from '../../../../types/chat-session'
import { PiniaColada } from '@pinia/colada'
import { createPinia } from 'pinia'
import { describe, expect, it, vi } from 'vitest'
import { render } from 'vitest-browser-vue'
import { createI18n } from 'vue-i18n'
import SessionsDrawer from './sessions-drawer.vue'
import { useChatStore } from '../../../../stores/chat'
import { useChatSessionStore } from '../../../../stores/chat/session-store'
function createTestI18n() {
return createI18n({
legacy: false,
locale: 'en',
missingWarn: false,
fallbackWarn: false,
messages: {
en: {
stage: {
chat: {
sessions: {
'title': 'Chats',
'new': 'New chat',
'empty': 'No chats',
'delete': 'Delete',
'cloud-badge': 'Cloud synced',
},
},
},
},
},
})
}
function sessionMeta(sessionId: string, title: string, updatedAt: number): ChatSessionMeta {
return {
sessionId,
title,
characterId: 'default',
userId: 'local',
createdAt: updatedAt,
updatedAt,
}
}
function createSessionsPinia() {
const pinia = createPinia()
pinia.state.value = {
'chat-session-selection': {
activeSessionId: 'session-b',
},
'chat-session': {
sessionMetas: {
'session-a': sessionMeta('session-a', 'Chat A', 1),
'session-b': sessionMeta('session-b', 'Chat B', 3),
'session-c': sessionMeta('session-c', 'Chat C', 2),
},
sessionMessages: {
'session-a': [],
'session-b': [],
'session-c': [],
},
},
}
return pinia
}
describe('sessions drawer orchestration', () => {
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3743073795
it('preserves a newer selection while active-session deletion is pending for Issue #2085', async () => {
// ROOT CAUSE:
//
// Deletion captured that B was active, then unconditionally selected its
// fallback after the asynchronous leader action completed. A user choice
// of C made during that await was therefore overwritten by stale work.
const pinia = createSessionsPinia()
const screen = await render(SessionsDrawer, {
props: { modelValue: false },
global: {
plugins: [pinia, PiniaColada, createTestI18n()],
},
})
const chatSession = useChatSessionStore(pinia)
const chat = useChatStore(pinia)
let resolveDelete: (() => void) | undefined
vi.spyOn(chat, 'deleteSession').mockImplementationOnce(() => new Promise((resolve) => {
resolveDelete = resolve
}))
vi.spyOn(chatSession, 'loadSession').mockResolvedValue(true)
vi.spyOn(chatSession, 'setActiveSession').mockImplementation(async (sessionId) => {
chatSession.activeSessionId = sessionId
})
await screen.rerender({ modelValue: true })
await screen.getByRole('button', { name: 'Delete: Chat B' }).click()
await vi.waitFor(() => expect(chat.deleteSession).toHaveBeenCalledWith('session-b'))
await screen.getByRole('button', { name: /^Chat C / }).click()
expect(chatSession.activeSessionId).toBe('session-c')
resolveDelete?.()
await vi.waitFor(() => expect(chatSession.setActiveSession).toHaveBeenCalledTimes(1))
expect(chatSession.setActiveSession).toHaveBeenLastCalledWith('session-c')
expect(chatSession.activeSessionId).toBe('session-c')
})
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3743221030
it('preserves a newer selection while session creation is pending for Issue #2085', async () => {
// ROOT CAUSE:
//
// New-session creation awaited leader persistence, then unconditionally
// selected its result. Session rows stay enabled during that wait, so a
// newer row selection was overwritten when creation eventually resolved.
const pinia = createSessionsPinia()
const screen = await render(SessionsDrawer, {
props: { modelValue: false },
global: {
plugins: [pinia, PiniaColada, createTestI18n()],
},
})
const chatSession = useChatSessionStore(pinia)
let resolveCreate: ((sessionId: string) => void) | undefined
vi.spyOn(chatSession, 'createSession').mockImplementationOnce(() => new Promise((resolve) => {
resolveCreate = resolve
}))
vi.spyOn(chatSession, 'loadSession').mockResolvedValue(true)
vi.spyOn(chatSession, 'setActiveSession').mockImplementation(async (sessionId) => {
chatSession.activeSessionId = sessionId
})
await screen.rerender({ modelValue: true })
await screen.getByRole('button', { name: 'New chat' }).click()
await vi.waitFor(() => expect(chatSession.createSession).toHaveBeenCalledWith('default', { setActive: false }))
await screen.getByRole('button', { name: /^Chat C / }).click()
expect(chatSession.activeSessionId).toBe('session-c')
resolveCreate?.('session-new')
await vi.waitFor(() => expect(chatSession.setActiveSession).toHaveBeenCalledTimes(1))
expect(chatSession.setActiveSession).toHaveBeenLastCalledWith('session-c')
expect(chatSession.activeSessionId).toBe('session-c')
})
})
@@ -3,39 +3,20 @@ import type { ChatSessionMeta } from '../../../../types/chat-session'
import { useResizeObserver, useScreenSafeArea } from '@vueuse/core' import { useResizeObserver, useScreenSafeArea } from '@vueuse/core'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import { DrawerContent, DrawerHandle, DrawerOverlay, DrawerPortal, DrawerRoot, DrawerTitle } from 'vaul-vue'
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import SessionsDialog from './sessions-dialog.vue'
import { useAnalytics } from '../../../../composables/use-analytics' import { useAnalytics } from '../../../../composables/use-analytics'
import { useBreakpoints } from '../../../../composables/use-breakpoints' import { useBreakpoints } from '../../../../composables/use-breakpoints'
import { extractMessageText } from '../../../../libs/chat-sync' import { extractMessageText } from '../../../../libs/chat-sync'
import { useAuthStore } from '../../../../stores/auth' import { useAuthStore } from '../../../../stores/auth'
import { useChatStore } from '../../../../stores/chat'
import { useChatSessionStore } from '../../../../stores/chat/session-store' import { useChatSessionStore } from '../../../../stores/chat/session-store'
import { useAiriCardStore } from '../../../../stores/modules/airi-card' import { useAiriCardStore } from '../../../../stores/modules/airi-card'
import { useConsciousnessStore } from '../../../../stores/modules/consciousness' import { useConsciousnessStore } from '../../../../stores/modules/consciousness'
/**
* Bottom-sheet (mobile) / centered-modal (desktop) UI surface that lists every
* chat session belonging to the current user, lets the user switch between
* them, and start a fresh session for the active character.
*
* Use when:
* - The user is on a stage page and wants to browse / switch conversations.
* Mounted once near the global ChatArea so any input bar can flip the
* `v-model` open.
*
* Expects:
* - `useChatSessionStore` is initialized — `sessionMetas` and `activeSessionId`
* drive the list, and switching calls `setActiveSession` / `createSession`.
*
* Returns:
* - A scrollable list. List items render the session title (or first user
* message preview as a fallback), a cloud-sync badge, and a relative
* updatedAt timestamp.
*/
const showDialog = defineModel({ type: Boolean, default: false, required: false }) const showDialog = defineModel({ type: Boolean, default: false, required: false })
const { isDesktop } = useBreakpoints() const { isDesktop } = useBreakpoints()
@@ -43,18 +24,15 @@ const screenSafeArea = useScreenSafeArea()
const { t } = useI18n() const { t } = useI18n()
const chatSession = useChatSessionStore() const chatSession = useChatSessionStore()
const chat = useChatStore()
const { sessionMetas, sessionMessages, activeSessionId } = storeToRefs(chatSession) const { sessionMetas, sessionMessages, activeSessionId } = storeToRefs(chatSession)
const { activeCardId } = storeToRefs(useAiriCardStore()) const { activeCardId } = storeToRefs(useAiriCardStore())
const { userId } = storeToRefs(useAuthStore()) const { userId } = storeToRefs(useAuthStore())
const { activeModel } = storeToRefs(useConsciousnessStore()) const { activeModel } = storeToRefs(useConsciousnessStore())
const { trackChatSessionSelected, trackChatSessionStarted } = useAnalytics() const { trackChatSessionSelected, trackChatSessionStarted } = useAnalytics()
// Re-entry guard for the "new session" button. Without this, a rapid // Creating includes persistence and cloud reconciliation, so prevent a
// double-click would call `createSession` twice (creating two orphan // second click from creating an orphan session while the first is pending.
// sessions) and emit duplicate `chat_session_started` analytics events.
// The async `createSession` includes IndexedDB writes + a cloud reconcile
// kick-off, so even a single click can stay in flight long enough for a
// second click to slip through.
const isCreatingSession = ref(false) const isCreatingSession = ref(false)
useResizeObserver(document.documentElement, () => screenSafeArea.update()) useResizeObserver(document.documentElement, () => screenSafeArea.update())
@@ -67,30 +45,18 @@ interface SessionRow {
updatedAtLabel: string updatedAtLabel: string
} }
/** // Keep another account's sessions hidden while an account swap rehydrates.
* Sessions visible in the drawer. Filters by the currently effective user
* (`userId.value || 'local'`) so:
* - Anonymous users see their local-only sessions (previously hidden by a
* blanket `userId !== 'local'` filter).
* - After an account swap, the previously signed-in user's sessions stay
* hidden until ensureActiveSessionForCharacter rehydrates the new tenant
* (the session-store also clears in-memory state on user change as a
* defense in depth).
*/
const ownedSessions = computed(() => { const ownedSessions = computed(() => {
const effectiveUserId = userId.value || 'local' const effectiveUserId = userId.value || 'local'
return Object.values(sessionMetas.value).filter(meta => meta.userId === effectiveUserId) return Object.values(sessionMetas.value).filter(meta => meta.userId === effectiveUserId)
}) })
/** /**
* Pull a 1-line preview from the first non-system message; falls back to the * Normalizes a session into its one-line drawer preview.
* stored title or a generic placeholder when nothing readable is available.
* *
* Before: * @example
* - messages: [system, { role: 'user', content: 'Tell me about the moon today' }, ...] * previewFor({ title: 'Moon notes', ...meta })
* * // => 'Moon notes'
* After:
* - "Tell me about the moon today"
*/ */
function previewFor(meta: ChatSessionMeta): string { function previewFor(meta: ChatSessionMeta): string {
if (meta.title) if (meta.title)
@@ -118,13 +84,11 @@ const RELATIVE_UNITS: Array<[Intl.RelativeTimeFormatUnit, number]> = [
] ]
/** /**
* Format an epoch ms timestamp as a coarse relative label like "3 minutes ago". * Normalizes an epoch timestamp into a coarse relative label.
* *
* Before: * @example
* - Date.now() - 5 * 60 * 1000 * formatUpdatedAt(Date.now() - 5 * 60 * 1000)
* * // => '5 minutes ago'
* After:
* - "5 minutes ago"
*/ */
function formatUpdatedAt(ts: number): string { function formatUpdatedAt(ts: number): string {
const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
@@ -147,12 +111,15 @@ const rows = computed<SessionRow[]>(() => {
isActive: meta.sessionId === activeSessionId.value, isActive: meta.sessionId === activeSessionId.value,
updatedAtLabel: formatUpdatedAt(meta.updatedAt), updatedAtLabel: formatUpdatedAt(meta.updatedAt),
})) }))
// Most-recent first; the active session usually ends up at the top after a
// fresh send because `persistSession` bumps `updatedAt`.
list.sort((a, b) => b.meta.updatedAt - a.meta.updatedAt) list.sort((a, b) => b.meta.updatedAt - a.meta.updatedAt)
return list return list
}) })
const mobilePaddingBottom = computed(() => {
const safeAreaBottom = Number.parseFloat(screenSafeArea.bottom.value.replace('px', ''))
return `${Math.max(safeAreaBottom, 24)}px`
})
async function selectSession(sessionId: string) { async function selectSession(sessionId: string) {
const selectedRow = rows.value.find(row => row.meta.sessionId === sessionId) const selectedRow = rows.value.find(row => row.meta.sessionId === sessionId)
if (sessionId !== activeSessionId.value && selectedRow) { if (sessionId !== activeSessionId.value && selectedRow) {
@@ -162,7 +129,7 @@ async function selectSession(sessionId: string) {
cloud_synced: !!selectedRow.meta.cloudChatId, cloud_synced: !!selectedRow.meta.cloudChatId,
}) })
} }
chatSession.setActiveSession(sessionId) await chatSession.setActiveSession(sessionId)
showDialog.value = false showDialog.value = false
} }
@@ -172,12 +139,16 @@ async function startNewSession() {
isCreatingSession.value = true isCreatingSession.value = true
try { try {
const characterId = activeCardId.value || 'default' const characterId = activeCardId.value || 'default'
await chatSession.createSession(characterId, { setActive: true }) const selectionBeforeCreation = activeSessionId.value
// PostHog retention denominator. We pick this call site (UI new-session // Creation runs in the synchronized leader, while navigation belongs to
// button) rather than `createSession` in the store because the store also // this window. Activating inside createSession would navigate the leader.
// creates sessions for cloud-reconcile / fork / restore flows that aren't const sessionId = await chatSession.createSession(characterId, { setActive: false })
// user-initiated. Model id is informational; sessionIndex is omitted // Rows remain interactive while creation is persisted in the leader. Do
// (PostHog can compute it from per-user event ordering). // not let that stale continuation replace a newer local user selection.
if (activeSessionId.value === selectionBeforeCreation)
await chatSession.setActiveSession(sessionId)
// Store-created sessions also include restore and fork flows; only this
// user action belongs in the retention denominator.
trackChatSessionStarted(activeModel.value || 'unknown') trackChatSessionStarted(activeModel.value || 'unknown')
showDialog.value = false showDialog.value = false
} }
@@ -186,13 +157,6 @@ async function startNewSession() {
} }
} }
async function deleteRow(event: Event, sessionId: string) {
// Stop the parent button's click — otherwise we'd switch into the session
// we are about to remove and immediately need a fallback.
event.stopPropagation()
await chatSession.deleteSession(sessionId)
}
// Per-open generation counter. The batch loadSession loop checks this before // Per-open generation counter. The batch loadSession loop checks this before
// each batch so closing the drawer mid-load aborts cleanly instead of // each batch so closing the drawer mid-load aborts cleanly instead of
// continuing to hydrate sessions the user has navigated away from. Without // continuing to hydrate sessions the user has navigated away from. Without
@@ -200,17 +164,11 @@ async function deleteRow(event: Event, sessionId: string) {
// re-added to `loadedSessions` as a phantom entry. // re-added to `loadedSessions` as a phantom entry.
let openGeneration = 0 let openGeneration = 0
// Re-render relative timestamps + hydrate non-active session messages when
// the drawer opens so each row can show a real preview instead of the
// fallback. `loadSession` is idempotent (`loadedSessions` set), so reopening
// the drawer is cheap.
watch(showDialog, async (open) => { watch(showDialog, async (open) => {
if (!open) if (!open)
return return
openGeneration += 1 openGeneration += 1
const myGeneration = openGeneration const myGeneration = openGeneration
// Touch `rows` first so reactive labels reflect a fresh `Date.now()`.
void rows.value
const knownSessionIds = ownedSessions.value.map(meta => meta.sessionId) const knownSessionIds = ownedSessions.value.map(meta => meta.sessionId)
// Bounded concurrency keeps a long history list from spawning a hundred // Bounded concurrency keeps a long history list from spawning a hundred
// simultaneous IndexedDB transactions; 4 in flight is plenty for a list // simultaneous IndexedDB transactions; 4 in flight is plenty for a list
@@ -225,172 +183,18 @@ watch(showDialog, async (open) => {
</script> </script>
<template> <template>
<DialogRoot v-if="isDesktop" :open="showDialog" @update:open="value => showDialog = value"> <SessionsDialog
<slot name="trigger" /> v-model:open="showDialog"
<DialogPortal> :rows="rows"
<DialogOverlay :is-desktop="isDesktop"
:class="[ :is-creating-session="isCreatingSession"
'fixed inset-0 z-[9999] bg-black/50 backdrop-blur-sm', :mobile-padding-bottom="mobilePaddingBottom"
'data-[state=closed]:animate-fadeOut data-[state=open]:animate-fadeIn', @new-session="startNewSession"
]" @select-session="selectSession"
/> @delete-session="chat.deleteSession"
<DialogContent >
:class="[ <template #trigger>
'fixed left-1/2 top-1/2 z-[9999] max-h-[80dvh] max-w-md w-[92dvw] transform overflow-hidden rounded-2xl bg-white/95 shadow-xl outline-none backdrop-blur-md scrollbar-none -translate-x-1/2 -translate-y-1/2 data-[state=closed]:animate-contentHide data-[state=open]:animate-contentShow dark:bg-neutral-900/90', <slot name="trigger" />
]" </template>
> </SessionsDialog>
<div :class="['flex flex-col h-full max-h-[80dvh]']">
<div :class="['flex items-center justify-between px-5 pt-5 pb-3']">
<DialogTitle :class="['text-base font-medium text-neutral-700 dark:text-neutral-200']">
{{ t('stage.chat.sessions.title') }}
</DialogTitle>
<button
:class="[
'rounded-lg px-3 py-1.5 text-xs font-medium',
'bg-primary-100/60 text-primary-700 dark:bg-primary-900/40 dark:text-primary-200',
'hover:bg-primary-200/70 dark:hover:bg-primary-800/50',
'transition-colors',
]"
:disabled="isCreatingSession"
@click="startNewSession"
>
{{ t('stage.chat.sessions.new') }}
</button>
</div>
<div :class="['flex-1 overflow-y-auto px-2 pb-4']">
<div v-if="rows.length === 0" :class="['p-6 text-center text-sm text-neutral-500 dark:text-neutral-400']">
{{ t('stage.chat.sessions.empty') }}
</div>
<div
v-for="row in rows"
:key="row.meta.sessionId"
:class="[
'group relative w-full rounded-xl mb-1',
'transition-colors',
row.isActive
? 'bg-primary-100/70 dark:bg-primary-900/40'
: 'hover:bg-neutral-100/80 dark:hover:bg-neutral-800/60',
]"
>
<button
:class="['w-full text-left px-3 py-2.5 outline-none flex flex-col gap-1']"
@click="selectSession(row.meta.sessionId)"
>
<div :class="['flex items-center gap-2 text-sm font-medium text-neutral-700 dark:text-neutral-200']">
<span :class="['truncate flex-1']">{{ row.preview }}</span>
<span
v-if="row.meta.cloudChatId"
:class="['shrink-0 text-[10px] uppercase tracking-wide rounded px-1.5 py-0.5', 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300']"
:title="t('stage.chat.sessions.cloud-badge')"
>
cloud
</span>
<!-- placeholder for trash icon to reserve hit space -->
<span :class="['w-7']" />
</div>
<div :class="['text-[11px] text-neutral-500 dark:text-neutral-400']">
{{ row.updatedAtLabel }}
</div>
</button>
<button
:class="[
'absolute right-2 top-2 h-7 w-7 flex items-center justify-center rounded-md',
'opacity-0 group-hover:opacity-100 focus:opacity-100',
'text-neutral-400 hover:text-red-500 hover:bg-red-500/10',
'transition-opacity duration-150',
]"
:title="t('stage.chat.sessions.delete')"
@click="deleteRow($event, row.meta.sessionId)"
>
<div class="i-solar:trash-bin-trash-bold-duotone h-4 w-4" />
</button>
</div>
</div>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
<DrawerRoot v-else :open="showDialog" should-scale-background @update:open="value => showDialog = value">
<DrawerPortal>
<DrawerOverlay :class="['fixed inset-0']" />
<DrawerContent
:class="[
'fixed bottom-0 left-0 right-0 z-1000',
'mt-20 px-2 pt-3',
'flex flex-col',
'h-full max-h-[85%]',
'rounded-t-[32px] outline-none backdrop-blur-md',
'bg-neutral-50/95 dark:bg-neutral-900/95',
]"
:style="{ paddingBottom: `${Math.max(Number.parseFloat(screenSafeArea.bottom.value.replace('px', '')), 24)}px` }"
>
<DrawerHandle :class="['[div&]:bg-neutral-400 [div&]:dark:bg-neutral-600']" />
<div :class="['flex items-center justify-between px-4 pt-3 pb-2']">
<DrawerTitle :class="['text-base font-medium text-neutral-700 dark:text-neutral-200']">
{{ t('stage.chat.sessions.title') }}
</DrawerTitle>
<button
:class="[
'rounded-lg px-3 py-1.5 text-xs font-medium',
'bg-primary-100/60 text-primary-700 dark:bg-primary-900/40 dark:text-primary-200',
'hover:bg-primary-200/70 dark:hover:bg-primary-800/50',
'transition-colors',
]"
:disabled="isCreatingSession"
@click="startNewSession"
>
{{ t('stage.chat.sessions.new') }}
</button>
</div>
<div :class="['flex-1 overflow-y-auto px-2 pb-2']">
<div v-if="rows.length === 0" :class="['p-6 text-center text-sm text-neutral-500 dark:text-neutral-400']">
{{ t('stage.chat.sessions.empty') }}
</div>
<div
v-for="row in rows"
:key="row.meta.sessionId"
:class="[
'group relative w-full rounded-xl mb-1',
'transition-colors',
row.isActive
? 'bg-primary-100/70 dark:bg-primary-900/40'
: 'hover:bg-neutral-100/80 dark:hover:bg-neutral-800/60',
]"
>
<button
:class="['w-full text-left px-3 py-3 outline-none flex flex-col gap-1']"
@click="selectSession(row.meta.sessionId)"
>
<div :class="['flex items-center gap-2 text-sm font-medium text-neutral-700 dark:text-neutral-200']">
<span :class="['truncate flex-1']">{{ row.preview }}</span>
<span
v-if="row.meta.cloudChatId"
:class="['shrink-0 text-[10px] uppercase tracking-wide rounded px-1.5 py-0.5', 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300']"
:title="t('stage.chat.sessions.cloud-badge')"
>
cloud
</span>
<span :class="['w-7']" />
</div>
<div :class="['text-[11px] text-neutral-500 dark:text-neutral-400']">
{{ row.updatedAtLabel }}
</div>
</button>
<button
:class="[
'absolute right-2 top-2 h-7 w-7 flex items-center justify-center rounded-md',
'opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100',
'text-neutral-400 hover:text-red-500 hover:bg-red-500/10',
'transition-opacity duration-150',
]"
:title="t('stage.chat.sessions.delete')"
@click="deleteRow($event, row.meta.sessionId)"
>
<div class="i-solar:trash-bin-trash-bold-duotone h-4 w-4" />
</button>
</div>
</div>
</DrawerContent>
</DrawerPortal>
</DrawerRoot>
</template> </template>
@@ -239,11 +239,13 @@ describe('store character-orchestrator', () => {
forceTextResponse: true, forceTextResponse: true,
}) })
const streamOptions = mockStream.mock.calls[0][3] const streamOptions = mockStream.mock.lastCall?.[3]
expect(streamOptions.supportsTools).toBe(false) expect(streamOptions).toMatchObject({
expect(streamOptions.waitForTools).toBe(false) supportsTools: false,
expect(streamOptions.tools).toEqual([]) tools: [],
expect(streamOptions.toolChoice).toBeUndefined() waitForTools: false,
})
expect(streamOptions?.toolChoice).toBeUndefined()
expect(onDelta).toHaveBeenCalled() expect(onDelta).toHaveBeenCalled()
expect(onEnd).toHaveBeenCalled() expect(onEnd).toHaveBeenCalled()
}) })
@@ -290,12 +292,14 @@ describe('store character-orchestrator', () => {
forceSparkCommandResponse: true, forceSparkCommandResponse: true,
}) })
const streamOptions = mockStream.mock.calls[0][3] const streamOptions = mockStream.mock.lastCall?.[3]
expect(streamOptions.supportsTools).toBe(true) expect(streamOptions).toMatchObject({
expect(streamOptions.waitForTools).toBe(true) supportsTools: true,
expect(streamOptions.toolChoice).toEqual({ toolChoice: {
type: 'function', type: 'function',
function: { name: 'builtIn_sparkCommand' }, function: { name: 'builtIn_sparkCommand' },
},
waitForTools: true,
}) })
expect(result?.commands?.length).toBe(1) expect(result?.commands?.length).toBe(1)
expect(sendSparkCommandMock).toHaveBeenCalledWith({ expect(sendSparkCommandMock).toHaveBeenCalledWith({
@@ -336,7 +340,7 @@ describe('store character-orchestrator', () => {
}, },
}) })
const renderedMessages = mockStream.mock.calls[0]?.[2] as Array<{ role: string, content: string }> | undefined const renderedMessages = mockStream.mock.lastCall?.[2] as Array<{ role: string, content: string }> | undefined
expect(String(renderedMessages?.[0]?.content)).toContain('Plugin-specific hint') expect(String(renderedMessages?.[0]?.content)).toContain('Plugin-specific hint')
expect(String(renderedMessages?.[1]?.content)).toContain('Rendered board snapshot') expect(String(renderedMessages?.[1]?.content)).toContain('Rendered board snapshot')
}) })
@@ -1,6 +1,7 @@
import type { ChatProvider } from '@xsai-ext/providers/utils' import type { ChatProvider } from '@xsai-ext/providers/utils'
import type { Message, Tool } from '@xsai/shared-chat' import type { Message, Tool } from '@xsai/shared-chat'
import { errorMessageFrom } from '@moeru/std'
import { IOAttributes, IOSpanNames } from '@proj-airi/stage-shared' import { IOAttributes, IOSpanNames } from '@proj-airi/stage-shared'
import { createPinia, setActivePinia } from 'pinia' import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -71,6 +72,8 @@ const createMinecraftContextMock = vi.fn()
const persistSessionMessagesMock = vi.fn() const persistSessionMessagesMock = vi.fn()
const forkSessionMock = vi.fn() const forkSessionMock = vi.fn()
const ensureSessionMock = vi.fn() const ensureSessionMock = vi.fn()
const loadSessionMock = vi.fn()
const deleteSessionMock = vi.fn()
const getProviderInstanceMock = vi.fn() const getProviderInstanceMock = vi.fn()
const getToolsByNamesMock = vi.fn<(names: string[]) => Tool[]>() const getToolsByNamesMock = vi.fn<(names: string[]) => Tool[]>()
@@ -194,6 +197,9 @@ vi.mock('./chat/session-store', () => ({
sessionMessages[sessionId] = [] sessionMessages[sessionId] = []
}, },
getSessionMessages: (sessionId: string) => sessionMessages[sessionId] ?? [], getSessionMessages: (sessionId: string) => sessionMessages[sessionId] ?? [],
getSessionMessagesIfLoaded: (sessionId: string) => sessionMessages[sessionId],
loadSession: loadSessionMock,
deleteSession: deleteSessionMock,
persistSessionMessages: persistSessionMessagesMock, persistSessionMessages: persistSessionMessagesMock,
getSessionGeneration: () => currentGeneration, getSessionGeneration: () => currentGeneration,
setSessionMessages: (sessionId: string, messages: any[]) => { setSessionMessages: (sessionId: string, messages: any[]) => {
@@ -285,6 +291,8 @@ describe('chat store contract', () => {
persistSessionMessagesMock.mockReset() persistSessionMessagesMock.mockReset()
forkSessionMock.mockReset() forkSessionMock.mockReset()
ensureSessionMock.mockReset() ensureSessionMock.mockReset()
loadSessionMock.mockReset().mockResolvedValue(true)
deleteSessionMock.mockReset().mockResolvedValue(undefined)
getProviderInstanceMock.mockReset().mockResolvedValue(provider) getProviderInstanceMock.mockReset().mockResolvedValue(provider)
getToolsByNamesMock.mockReset().mockImplementation(names => names.map(name => ({ getToolsByNamesMock.mockReset().mockImplementation(names => names.map(name => ({
type: 'function', type: 'function',
@@ -338,6 +346,51 @@ describe('chat store contract', () => {
]) ])
}) })
// https://github.com/moeru-ai/airi/issues/2085
it('hydrates the target session before sending for Issue #2085', async () => {
// ROOT CAUSE:
//
// A synchronized follower could target a session known only by metadata.
// Reading through getSessionMessages before hydration created a fresh
// system-only history that could overwrite the persisted conversation.
delete sessionMessages['session-2']
loadSessionMock.mockImplementationOnce(async () => {
sessionMessages['session-2'] = [
{ role: 'system', content: 'persisted system prompt', createdAt: 1, id: 'system-2' },
]
return true
})
llmStreamMock.mockImplementationOnce(async (_model: string, _chatProvider: ChatProvider, _messages: Message[], options: any) => {
await options.onStreamEvent({ type: 'finish', finishReason: 'stop' })
})
const store = useChatStore()
await store.send({ sessionId: 'session-2', text: 'continue persisted chat' })
expect(loadSessionMock).toHaveBeenCalledWith('session-2')
expect(loadSessionMock.mock.invocationCallOrder[0]).toBeLessThan(ensureSessionMock.mock.invocationCallOrder[0])
expect(sessionMessages['session-2']?.[0]).toMatchObject({
content: 'persisted system prompt',
id: 'system-2',
})
})
// https://github.com/moeru-ai/airi/issues/2085
it('does not create fallback history when target hydration fails for Issue #2085', async () => {
delete sessionMessages['session-2']
loadSessionMock.mockResolvedValueOnce(false)
const store = useChatStore()
await expect(
store.send({ sessionId: 'session-2', text: 'do not overwrite history' }),
)
.rejects
.toThrow('Failed to load the target chat session')
expect(ensureSessionMock).not.toHaveBeenCalledWith('session-2')
expect(sessionMessages['session-2']).toBeUndefined()
})
it('forwards one correlation identity across every PostHog chat milestone', async () => { it('forwards one correlation identity across every PostHog chat milestone', async () => {
llmStreamMock.mockImplementation(async (_model: string, _chatProvider: ChatProvider, _messages: Message[], options: any) => { llmStreamMock.mockImplementation(async (_model: string, _chatProvider: ChatProvider, _messages: Message[], options: any) => {
await options.onStreamEvent({ type: 'text-delta', text: 'ok' }) await options.onStreamEvent({ type: 'text-delta', text: 'ok' })
@@ -650,30 +703,31 @@ describe('chat store contract', () => {
expect(specialHook.mock.calls[0]?.[1].turnId.length).toBeGreaterThan(0) expect(specialHook.mock.calls[0]?.[1].turnId.length).toBeGreaterThan(0)
}) })
/** // https://github.com/moeru-ai/airi/pull/2086#discussion_r3743261505
* @example it('preserves a synchronized sending snapshot without replaying it through the follower runtime for Issue #2085', async () => {
* store.sending = true // ROOT CAUSE:
* await nextTick() //
* expect(store.sending).toBe(true) // Applying `sending: true` invoked the follower's idle runtime, whose
*/ // derived state cleared the synchronized stream payload and could target
it('keeps sending writable for context bridge and chat sync consumers', async () => { // that follower's unrelated local selection.
const store = useChatStore() const store = useChatStore()
store.$patch({
expect(store.sending).toBe(false) sending: true,
activeSendSessionId: 'session-b',
store.sending = true activeStreamingMessage: {
role: 'assistant',
content: 'authority stream',
slices: [],
tool_results: [],
},
})
await nextTick() await nextTick()
expect(store.sending).toBe(true) expect(store.sending).toBe(true)
expect(store.activeSendSessionId).toBe('session-b')
store.sending = false expect(store.activeStreamingMessage?.content).toBe('authority stream')
await nextTick()
expect(store.sending).toBe(false)
}) })
/**
* @example
* store.sending = false while a local runtime send is still streaming.
*/
it('does not end the owned IO turn span when external sending mirror is cleared mid-send', async () => { it('does not end the owned IO turn span when external sending mirror is cleared mid-send', async () => {
let releaseStream: (() => void) | undefined let releaseStream: (() => void) | undefined
llmStreamMock.mockImplementationOnce(async () => { llmStreamMock.mockImplementationOnce(async () => {
@@ -711,11 +765,6 @@ describe('chat store contract', () => {
expect(ioTracerMocks.activeTurnSpan.value).toBeUndefined() expect(ioTracerMocks.activeTurnSpan.value).toBeUndefined()
}) })
/**
* @example
* createMinecraftContext() returns a runtime context update.
* The facade passes it into the core runtime before prompt snapshots are read.
*/
it('ingests runtime context providers before composing prompt snapshots', async () => { it('ingests runtime context providers before composing prompt snapshots', async () => {
const minecraftContext = { const minecraftContext = {
id: 'minecraft-context', id: 'minecraft-context',
@@ -788,11 +837,60 @@ describe('chat store contract', () => {
await firstSend await firstSend
}) })
/** // https://github.com/moeru-ai/airi/pull/2086#discussion_r3742939573
* @example it('does not recreate a deleted session when queued work is cancelled for Issue #2085', async () => {
* store.getPendingQueuedSendSnapshot() // ROOT CAUSE:
* // => [{ sessionId, generation, cancelled, messagePreview, hasAttachments, inputType }] //
*/ // Cancelling queued work rejects the public send action. Its generic error
// handler used to recreate a system-plus-error history after deletion,
// leaving a ghost conversation that no longer had session metadata.
let releaseFirstSend: (() => void) | undefined
llmStreamMock.mockImplementationOnce(async () => {
await new Promise<void>((resolve) => {
releaseFirstSend = resolve
})
})
deleteSessionMock.mockImplementationOnce(async () => {
currentGeneration += 1
delete sessionMessages['session-1']
})
const store = useChatStore()
const firstSend = store.send({
sessionId: 'session-1',
text: 'active turn',
})
const activeOutcome = firstSend.then(
() => 'resolved',
error => errorMessageFrom(error) ?? 'unknown error',
)
const queuedSend = store.send({
sessionId: 'session-1',
text: 'must be cancelled',
})
const queuedOutcome = queuedSend.then(
() => 'resolved',
error => errorMessageFrom(error) ?? 'unknown error',
)
await vi.waitFor(() => {
expect(llmStreamMock).toHaveBeenCalledTimes(1)
})
await vi.waitFor(() => {
expect(store.pendingQueuedSendCount).toBe(1)
})
await store.deleteSession('session-1')
expect(deleteSessionMock).toHaveBeenCalledWith('session-1')
expect(await queuedOutcome).toBe('Chat session was reset before send could start')
expect(sessionMessages['session-1']).toBeUndefined()
releaseFirstSend?.()
expect(await activeOutcome).toBe('Chat session was removed before send completed')
expect(llmStreamMock).toHaveBeenCalledTimes(1)
expect(sessionMessages['session-1']).toBeUndefined()
})
it('mirrors pending queued send snapshots from the core runtime', async () => { it('mirrors pending queued send snapshots from the core runtime', async () => {
let releaseFirstSend: (() => void) | undefined let releaseFirstSend: (() => void) | undefined
llmStreamMock.mockImplementationOnce(async () => { llmStreamMock.mockImplementationOnce(async () => {
+35 -11
View File
@@ -4,7 +4,7 @@ import type { ChatProvider } from '@xsai-ext/providers/utils'
import type { Message } from '@xsai/shared-chat' import type { Message } from '@xsai/shared-chat'
import type {} from 'pinia-plugin-synced' import type {} from 'pinia-plugin-synced'
import type { ChatHistoryItem, ChatToolReference } from '../types/chat' import type { ChatHistoryItem, ChatToolReference, StreamingAssistantMessage } from '../types/chat'
import type { ToolCallRerunPayload } from './tool-call-rerun' import type { ToolCallRerunPayload } from './tool-call-rerun'
import { errorMessageFrom } from '@moeru/std' import { errorMessageFrom } from '@moeru/std'
@@ -12,7 +12,7 @@ import { createChatOrchestratorRuntime } from '@proj-airi/core-agent'
import { IOAttributes, IOEvents, IOSpanNames, IOSubsystems } from '@proj-airi/stage-shared' import { IOAttributes, IOEvents, IOSpanNames, IOSubsystems } from '@proj-airi/stage-shared'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
import { defineStore, storeToRefs } from 'pinia' import { defineStore, storeToRefs } from 'pinia'
import { ref, toRaw, watch } from 'vue' import { shallowRef, toRaw } from 'vue'
import { getConversationAnalyticsSurface } from '../composables' import { getConversationAnalyticsSurface } from '../composables'
import { activeTurnSpan, startSpan } from '../composables/use-io-tracer' import { activeTurnSpan, startSpan } from '../composables/use-io-tracer'
@@ -157,8 +157,10 @@ export const useChatStore = defineStore('chat', () => {
const { activeSessionId } = storeToRefs(chatSession) const { activeSessionId } = storeToRefs(chatSession)
const { streamingMessage } = storeToRefs(chatStream) const { streamingMessage } = storeToRefs(chatStream)
const sending = ref(false) const sending = shallowRef(false)
const pendingQueuedSendCount = ref(0) const activeSendSessionId = shallowRef<string>()
const activeStreamingMessage = shallowRef<StreamingAssistantMessage>()
const pendingQueuedSendCount = shallowRef(0)
let ownedActiveTurnSpan: typeof activeTurnSpan.value let ownedActiveTurnSpan: typeof activeTurnSpan.value
const analyticsHooks = createChatAnalyticsHooks({ const analyticsHooks = createChatAnalyticsHooks({
getSessionMessages: sessionId => chatSession.getSessionMessages(sessionId), getSessionMessages: sessionId => chatSession.getSessionMessages(sessionId),
@@ -229,6 +231,8 @@ export const useChatStore = defineStore('chat', () => {
function syncRuntimeState(state: ChatOrchestratorRuntimeState) { function syncRuntimeState(state: ChatOrchestratorRuntimeState) {
sending.value = state.sending sending.value = state.sending
activeSendSessionId.value = state.activeSendSessionId
activeStreamingMessage.value = state.activeStreamingMessage
pendingQueuedSendCount.value = state.pendingQueuedSendCount pendingQueuedSendCount.value = state.pendingQueuedSendCount
} }
@@ -317,11 +321,6 @@ export const useChatStore = defineStore('chat', () => {
}, },
}) })
watch(sending, (next) => {
if (runtime.getSending() !== next)
runtime.setSending(next)
})
async function ingest( async function ingest(
sendingMessage: string, sendingMessage: string,
options: ChatOrchestratorSendOptions, options: ChatOrchestratorSendOptions,
@@ -345,6 +344,9 @@ export const useChatStore = defineStore('chat', () => {
} }
function appendSendError(sessionId: string, error: unknown) { function appendSendError(sessionId: string, error: unknown) {
if (!chatSession.getSessionMessagesIfLoaded(sessionId))
return
chatSession.appendSessionMessage(sessionId, { chatSession.appendSessionMessage(sessionId, {
role: 'error', role: 'error',
content: errorMessageFrom(error) ?? 'Unknown chat operation failure', content: errorMessageFrom(error) ?? 'Unknown chat operation failure',
@@ -357,6 +359,9 @@ export const useChatStore = defineStore('chat', () => {
if (!providerId || !modelId) if (!providerId || !modelId)
throw new Error('No active chat provider or model configured') throw new Error('No active chat provider or model configured')
if (!await chatSession.loadSession(payload.sessionId))
throw new Error('Failed to load the target chat session')
const messageCount = chatSession.getSessionMessages(payload.sessionId).length const messageCount = chatSession.getSessionMessages(payload.sessionId).length
const chatProvider = await providerStore.getProviderInstance<ChatProvider>(providerId) const chatProvider = await providerStore.getProviderInstance<ChatProvider>(providerId)
if (!chatProvider) if (!chatProvider)
@@ -376,8 +381,12 @@ export const useChatStore = defineStore('chat', () => {
}, },
}, payload.sessionId) }, payload.sessionId)
const completedMessages = chatSession.getSessionMessagesIfLoaded(payload.sessionId)
if (!completedMessages)
throw new Error('Chat session was removed before send completed')
return { return {
messages: chatSession.getSessionMessages(payload.sessionId) messages: completedMessages
.slice(messageCount) .slice(messageCount)
.map(message => structuredClone(toRaw(message))), .map(message => structuredClone(toRaw(message))),
sessionId: payload.sessionId, sessionId: payload.sessionId,
@@ -397,6 +406,9 @@ export const useChatStore = defineStore('chat', () => {
/** Replaces one stored turn with a new execution of its user message. */ /** Replaces one stored turn with a new execution of its user message. */
async function retry(payload: ChatRetryPayload): Promise<ChatSendResult> { async function retry(payload: ChatRetryPayload): Promise<ChatSendResult> {
if (!await chatSession.loadSession(payload.sessionId))
throw new Error('Failed to load the target chat session')
const currentMessages = chatSession.getSessionMessages(payload.sessionId) const currentMessages = chatSession.getSessionMessages(payload.sessionId)
const sourceIndex = retrySourceIndexFrom(currentMessages, payload.index) const sourceIndex = retrySourceIndexFrom(currentMessages, payload.index)
if (sourceIndex < 0) if (sourceIndex < 0)
@@ -424,6 +436,9 @@ export const useChatStore = defineStore('chat', () => {
/** Runs one stored tool call again and replaces its stored result. */ /** Runs one stored tool call again and replaces its stored result. */
async function rerunToolCall(payload: ChatToolCallRerunPayload): Promise<void> { async function rerunToolCall(payload: ChatToolCallRerunPayload): Promise<void> {
if (!await chatSession.loadSession(payload.sessionId))
throw new Error('Failed to load the target chat session')
const nextMessages = await executeToolCallRerun({ const nextMessages = await executeToolCallRerun({
messages: chatSession.getSessionMessages(payload.sessionId), messages: chatSession.getSessionMessages(payload.sessionId),
payload, payload,
@@ -442,6 +457,12 @@ export const useChatStore = defineStore('chat', () => {
chatStream.resetStream() chatStream.resetStream()
} }
/** Cancels queued work before permanently removing its owning session. */
function deleteSession(sessionId: string): Promise<void> {
runtime.cancelPendingSends(sessionId)
return chatSession.deleteSession(sessionId)
}
async function ingestOnFork( async function ingestOnFork(
sendingMessage: string, sendingMessage: string,
options: ChatOrchestratorSendOptions, options: ChatOrchestratorSendOptions,
@@ -470,9 +491,12 @@ export const useChatStore = defineStore('chat', () => {
return { return {
sending, sending,
activeSendSessionId,
activeStreamingMessage,
pendingQueuedSendCount, pendingQueuedSendCount,
cleanup, cleanup,
deleteSession,
ingest, ingest,
ingestOnFork, ingestOnFork,
rerunToolCall, rerunToolCall,
@@ -507,7 +531,7 @@ export const useChatStore = defineStore('chat', () => {
} }
}, { }, {
synced: { synced: {
actions: ['cleanup', 'rerunToolCall', 'retry', 'send'], actions: ['cleanup', 'deleteSession', 'rerunToolCall', 'retry', 'send'],
state: true, state: true,
}, },
}) })
@@ -1,7 +1,7 @@
import type { ChatSessionMeta, ChatSessionRecord, ChatSessionsIndex } from '../../types/chat-session' import type { ChatSessionMeta, ChatSessionRecord, ChatSessionsIndex } from '../../types/chat-session'
import { createPinia, setActivePinia } from 'pinia' import { createPinia, disposePinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue' import { nextTick, ref } from 'vue'
// Refs the store reads through the mocked `useAuthStore` / `useAiriCardStore`. // Refs the store reads through the mocked `useAuthStore` / `useAiriCardStore`.
@@ -19,6 +19,14 @@ const getOutboxMock = vi.fn<(uid: string) => Promise<any[]>>()
const dropOutboxForSessionMock = vi.fn<(uid: string, id: string) => Promise<void>>() const dropOutboxForSessionMock = vi.fn<(uid: string, id: string) => Promise<void>>()
const getTombstonesMock = vi.fn<(uid: string) => Promise<string[]>>() const getTombstonesMock = vi.fn<(uid: string) => Promise<string[]>>()
const removeTombstonesMock = vi.fn<(uid: string, ids: string[]) => Promise<void>>() const removeTombstonesMock = vi.fn<(uid: string, ids: string[]) => Promise<void>>()
const addTombstoneMock = vi.fn<(uid: string, id: string) => Promise<void>>()
const deleteCloudChatMock = vi.fn<(id: string) => Promise<void>>()
const listChatsMock = vi.fn()
const pullMessagesMock = vi.fn()
const reconcileLocalAndRemoteMock = vi.fn()
const connectCloudWsMock = vi.fn()
let cloudWsStatus: 'idle' | 'open' = 'idle'
let cloudStatusListener: ((status: 'idle' | 'open') => void) | undefined
vi.mock('pinia', async () => { vi.mock('pinia', async () => {
const actual = await vi.importActual<typeof import('pinia')>('pinia') const actual = await vi.importActual<typeof import('pinia')>('pinia')
@@ -52,7 +60,7 @@ vi.mock('../../database/repos/chat-sessions.repo', () => ({
updateOutboxEntries: vi.fn().mockResolvedValue(undefined), updateOutboxEntries: vi.fn().mockResolvedValue(undefined),
dropOutboxForSession: (uid: string, id: string) => dropOutboxForSessionMock(uid, id), dropOutboxForSession: (uid: string, id: string) => dropOutboxForSessionMock(uid, id),
getTombstones: (uid: string) => getTombstonesMock(uid), getTombstones: (uid: string) => getTombstonesMock(uid),
addTombstone: vi.fn().mockResolvedValue(undefined), addTombstone: (uid: string, id: string) => addTombstoneMock(uid, id),
removeTombstones: (uid: string, ids: string[]) => removeTombstonesMock(uid, ids), removeTombstones: (uid: string, ids: string[]) => removeTombstonesMock(uid, ids),
}, },
})) }))
@@ -74,20 +82,23 @@ vi.mock('../../libs/server', () => ({
// sufficient. We keep `extractMessageText` realistic so message previews work. // sufficient. We keep `extractMessageText` realistic so message previews work.
vi.mock('../../libs/chat-sync', () => ({ vi.mock('../../libs/chat-sync', () => ({
applyCreateActions: vi.fn().mockResolvedValue([]), applyCreateActions: vi.fn().mockResolvedValue([]),
reconcileLocalAndRemote: vi.fn().mockReturnValue({ adopt: [], claim: [], create: [] }), reconcileLocalAndRemote: (...args: unknown[]) => reconcileLocalAndRemoteMock(...args),
createCloudChatMapper: () => ({ createCloudChatMapper: () => ({
listChats: vi.fn().mockResolvedValue([]), listChats: () => listChatsMock(),
deleteChat: vi.fn().mockResolvedValue(undefined), deleteChat: (id: string) => deleteCloudChatMock(id),
}), }),
createChatWsClient: () => ({ createChatWsClient: () => ({
status: () => 'idle' as const, status: () => cloudWsStatus,
connect: vi.fn(), connect: connectCloudWsMock,
disconnect: vi.fn(), disconnect: vi.fn(),
destroy: vi.fn(), destroy: vi.fn(),
sendMessages: vi.fn().mockResolvedValue({ ok: true }), sendMessages: vi.fn().mockResolvedValue({ ok: true }),
pullMessages: vi.fn().mockResolvedValue({ messages: [], maxSeq: 0 }), pullMessages: (...args: unknown[]) => pullMessagesMock(...args),
onNewMessages: () => () => {}, onNewMessages: () => () => {},
onStatusChange: () => () => {}, onStatusChange: (listener: (status: 'idle' | 'open') => void) => {
cloudStatusListener = listener
return () => {}
},
}), }),
extractMessageText: (m: any) => (typeof m?.content === 'string' ? m.content : ''), extractMessageText: (m: any) => (typeof m?.content === 'string' ? m.content : ''),
isCloudSyncableMessage: () => false, isCloudSyncableMessage: () => false,
@@ -95,9 +106,11 @@ vi.mock('../../libs/chat-sync', () => ({
})) }))
const { useChatSessionStore } = await import('./session-store') const { useChatSessionStore } = await import('./session-store')
let pinia: ReturnType<typeof createPinia>
beforeEach(() => { beforeEach(() => {
setActivePinia(createPinia()) pinia = createPinia()
setActivePinia(pinia)
userIdRef.value = 'local' userIdRef.value = 'local'
activeCardIdRef.value = 'default' activeCardIdRef.value = 'default'
systemPromptRef.value = '' systemPromptRef.value = ''
@@ -111,6 +124,18 @@ beforeEach(() => {
dropOutboxForSessionMock.mockReset().mockResolvedValue(undefined) dropOutboxForSessionMock.mockReset().mockResolvedValue(undefined)
getTombstonesMock.mockReset().mockResolvedValue([]) getTombstonesMock.mockReset().mockResolvedValue([])
removeTombstonesMock.mockReset().mockResolvedValue(undefined) removeTombstonesMock.mockReset().mockResolvedValue(undefined)
addTombstoneMock.mockReset().mockResolvedValue(undefined)
deleteCloudChatMock.mockReset().mockResolvedValue(undefined)
listChatsMock.mockReset().mockResolvedValue([])
pullMessagesMock.mockReset().mockResolvedValue({ messages: [], seq: 0 })
reconcileLocalAndRemoteMock.mockReset().mockReturnValue({ adopt: [], claim: [], create: [] })
connectCloudWsMock.mockReset()
cloudWsStatus = 'idle'
cloudStatusListener = undefined
})
afterEach(() => {
disposePinia(pinia)
}) })
async function flushMicrotasks(rounds = 8) { async function flushMicrotasks(rounds = 8) {
@@ -295,6 +320,291 @@ describe('chat-session-store · loadSession vs concurrent deleteSession', () =>
}) })
}) })
describe('chat-session-store · deletion and hydration failures', () => {
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3743502031
it('persists the fallback without changing the leader selection when a follower deletes its active session for Issue #2085', async () => {
// ROOT CAUSE:
//
// Session selection is window-local, so the synchronized leader can be on
// A while the persisted index still points to B. Deleting B returned A but
// left the persisted index empty, so the next startup created a blank chat.
const sessionA: ChatSessionMeta = {
sessionId: 'session-a',
userId: 'local',
characterId: 'default',
createdAt: 1,
updatedAt: 1,
}
const sessionB: ChatSessionMeta = {
sessionId: 'session-b',
userId: 'local',
characterId: 'default',
createdAt: 2,
updatedAt: 2,
}
const store = useChatSessionStore()
store.applyRemoteSnapshot({
activeSessionId: 'session-a',
sessionMessages: { 'session-a': [], 'session-b': [] },
sessionMetas: { 'session-a': sessionA, 'session-b': sessionB },
index: {
userId: 'local',
characters: {
default: {
activeSessionId: 'session-b',
sessions: { 'session-a': sessionA, 'session-b': sessionB },
},
},
},
})
await store.deleteSession('session-b')
expect(store.activeSessionId).toBe('session-a')
expect(store.getSnapshot().index?.characters.default?.activeSessionId).toBe('session-a')
expect(store.sessionMetas['session-b']).toBeUndefined()
expect(saveIndexMock).toHaveBeenLastCalledWith(expect.objectContaining({
characters: expect.objectContaining({
default: expect.objectContaining({ activeSessionId: 'session-a' }),
}),
}))
})
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3743309237
it('persists a replacement fallback without changing an unrelated leader selection for Issue #2085', async () => {
// ROOT CAUSE:
//
// When a follower deleted the final session for one character, the leader
// created a replacement with local activation disabled. The replacement
// was indexed but the persisted character active ID stayed empty, so the
// next initialization created another blank conversation.
const leaderSession: ChatSessionMeta = {
sessionId: 'leader-session',
userId: 'local',
characterId: 'other-character',
createdAt: 1,
updatedAt: 1,
}
const deletedSession: ChatSessionMeta = {
sessionId: 'deleted-session',
userId: 'local',
characterId: 'default',
createdAt: 2,
updatedAt: 2,
}
const store = useChatSessionStore()
store.applyRemoteSnapshot({
activeSessionId: 'leader-session',
sessionMessages: { 'leader-session': [], 'deleted-session': [] },
sessionMetas: { 'leader-session': leaderSession, 'deleted-session': deletedSession },
index: {
userId: 'local',
characters: {
'other-character': {
activeSessionId: 'leader-session',
sessions: { 'leader-session': leaderSession },
},
'default': {
activeSessionId: 'deleted-session',
sessions: { 'deleted-session': deletedSession },
},
},
},
})
await store.deleteSession('deleted-session')
const snapshot = store.getSnapshot()
const [replacementSessionId] = Object.keys(snapshot.index?.characters.default?.sessions ?? {})
expect(replacementSessionId).toBeDefined()
if (!replacementSessionId)
throw new Error('Expected deletion to create a replacement session')
expect(store.activeSessionId).toBe('leader-session')
expect(snapshot.index?.characters.default?.activeSessionId).toBe(replacementSessionId)
expect(snapshot.index?.characters.default?.sessions[replacementSessionId]).toBeDefined()
expect(saveIndexMock).toHaveBeenLastCalledWith(expect.objectContaining({
characters: expect.objectContaining({
default: expect.objectContaining({ activeSessionId: replacementSessionId }),
}),
}))
})
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3628917803
it('keeps deleted session generations invalid for Issue #2085', async () => {
// ROOT CAUSE:
//
// Deletion previously removed the generation entry. A send captured at
// generation zero could then read the deleted session as generation zero
// again and continue appending messages after the chat was gone.
const meta: ChatSessionMeta = {
sessionId: 'sess-1',
userId: 'local',
characterId: 'default',
createdAt: 1,
updatedAt: 1,
}
const store = useChatSessionStore()
store.applyRemoteSnapshot({
activeSessionId: 'sess-1',
sessionMessages: { 'sess-1': [] },
sessionMetas: { 'sess-1': meta },
index: null,
})
expect(store.getSessionGeneration('sess-1')).toBe(0)
await store.deleteSession('sess-1')
expect(store.getSessionGeneration('sess-1')).toBe(1)
})
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3628003766
it('reports hydration failure and permits a later retry for Issue #2085', async () => {
const meta: ChatSessionMeta = {
sessionId: 'sess-1',
userId: 'local',
characterId: 'default',
createdAt: 1,
updatedAt: 1,
}
vi.spyOn(console, 'warn').mockImplementation(() => {})
getSessionMock
.mockRejectedValueOnce(new Error('IndexedDB read failed'))
.mockResolvedValueOnce(null)
userIdRef.value = 'local'
const store = useChatSessionStore()
store.applyRemoteSnapshot({
activeSessionId: '',
sessionMessages: {},
sessionMetas: { 'sess-1': meta },
index: null,
})
await expect(store.loadSession('sess-1')).resolves.toBe(false)
await expect(store.loadSession('sess-1')).resolves.toBe(true)
expect(getSessionMock).toHaveBeenCalledTimes(2)
})
})
describe('chat-session-store · cloud placeholder hydration', () => {
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3743502032
it('retries cloud hydration when the reconcile pull for an adopted placeholder fails for Issue #2085', async () => {
// ROOT CAUSE:
//
// Reconcile creates a system-only placeholder before its first cloud pull.
// If that pull fails, loadSession sees the message-map entry and marks the
// placeholder as loaded. Selecting the chat then skips every later pull.
const localMeta: ChatSessionMeta = {
sessionId: 'local-session',
userId: 'cloud-user',
characterId: 'default',
createdAt: 1,
updatedAt: 1,
}
const remoteChat = {
id: 'remote-session',
type: 'bot' as const,
title: null,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
}
userIdRef.value = 'cloud-user'
getIndexMock.mockResolvedValue({
userId: 'cloud-user',
characters: {
default: {
activeSessionId: localMeta.sessionId,
sessions: { [localMeta.sessionId]: localMeta },
},
},
})
getSessionMock.mockImplementation((sessionId) => {
if (sessionId === remoteChat.id) {
return Promise.resolve({
meta: {
sessionId: remoteChat.id,
userId: 'cloud-user',
characterId: 'default',
createdAt: Date.parse(remoteChat.createdAt),
updatedAt: Date.parse(remoteChat.updatedAt),
cloudChatId: remoteChat.id,
},
messages: [],
})
}
return Promise.resolve({ meta: localMeta, messages: [] })
})
listChatsMock.mockResolvedValue([remoteChat])
reconcileLocalAndRemoteMock.mockReturnValue({ adopt: [remoteChat], claim: [], create: [] })
pullMessagesMock
.mockRejectedValueOnce(new Error('temporary cloud failure'))
.mockResolvedValueOnce({ messages: [], seq: 0 })
vi.spyOn(console, 'warn').mockImplementation(() => {})
const store = useChatSessionStore()
await store.initialize()
expect(cloudStatusListener).toBeDefined()
cloudWsStatus = 'open'
cloudStatusListener?.('open')
await vi.waitFor(() => {
expect(store.cloudSyncReady).toBe(true)
})
expect(pullMessagesMock).toHaveBeenCalledTimes(1)
await store.setActiveSession(remoteChat.id)
expect(pullMessagesMock).toHaveBeenCalledTimes(2)
})
})
describe('chat-session-store · cloud deletion', () => {
it('tombstones an unmapped cloud session before an in-flight create can finish', async () => {
// ROOT CAUSE:
//
// A newly created cloud session can be deleted before POST /chats binds
// its cloud id. Without a tombstone for the deterministic session id, the
// completed remote create is adopted again by the next reconcile.
userIdRef.value = 'cloud-user'
const deleted: ChatSessionMeta = {
sessionId: 'pending-cloud-session',
userId: 'cloud-user',
characterId: 'default',
createdAt: 1,
updatedAt: 1,
}
const survivor: ChatSessionMeta = {
sessionId: 'surviving-session',
userId: 'cloud-user',
characterId: 'default',
createdAt: 2,
updatedAt: 2,
}
const store = useChatSessionStore()
store.applyRemoteSnapshot({
activeSessionId: 'surviving-session',
sessionMessages: { 'pending-cloud-session': [], 'surviving-session': [] },
sessionMetas: { 'pending-cloud-session': deleted, 'surviving-session': survivor },
index: {
userId: 'cloud-user',
characters: {
default: {
activeSessionId: 'pending-cloud-session',
sessions: { 'pending-cloud-session': deleted, 'surviving-session': survivor },
},
},
},
})
await store.deleteSession('pending-cloud-session')
expect(addTombstoneMock).toHaveBeenCalledWith('cloud-user', 'pending-cloud-session')
expect(deleteCloudChatMock).not.toHaveBeenCalled()
})
})
describe('chat-session-store · active card prompt edits', () => { describe('chat-session-store · active card prompt edits', () => {
// ROOT CAUSE: // ROOT CAUSE:
// //
@@ -394,6 +704,257 @@ describe('chat-session-store · active card prompt edits', () => {
}) })
describe('chat-session-store · synchronized data actions', () => { describe('chat-session-store · synchronized data actions', () => {
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3755711151
it('keeps cloud synchronization in the elected leader for Issue #2085', async () => {
// ROOT CAUSE:
//
// Window-local initialization opened a cloud WebSocket in every window.
// Follower callbacks then proposed direct full-state mutations.
const store = useChatSessionStore()
store.setCloudSyncOwnership(false)
await store.initialize()
userIdRef.value = 'cloud-user'
await nextTick()
expect(connectCloudWsMock).not.toHaveBeenCalled()
store.setCloudSyncOwnership(true)
expect(connectCloudWsMock).toHaveBeenCalledTimes(1)
})
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3743242525
it('initializes a new window selection from the synchronized index for Issue #2085', async () => {
// ROOT CAUSE:
//
// `ready` was synchronized while selection was not. A joining window saw
// the leader's ready flag, skipped initialization, and remained on an
// empty local selection.
const session: ChatSessionMeta = {
sessionId: 'session-b',
userId: 'local',
characterId: 'default',
createdAt: 1,
updatedAt: 1,
}
const store = useChatSessionStore()
store.$patch({
sessionMessages: { 'session-b': [{ id: 'system', role: 'system', content: 'prompt' }] },
sessionMetas: { 'session-b': session },
index: {
userId: 'local',
characters: {
default: {
activeSessionId: 'session-b',
sessions: { 'session-b': session },
},
},
},
})
expect(store.$state).not.toHaveProperty('ready')
expect(store.activeSessionId).toBe('')
await store.initialize()
expect(store.activeSessionId).toBe('session-b')
expect(store.isReady).toBe(true)
})
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3743242529
it('trusts synchronized messages instead of merging a stale follower IDB record for Issue #2085', async () => {
// ROOT CAUSE:
//
// Follower hydration read its own older IndexedDB record and mutated the
// fully synchronized store, allowing that stale snapshot to overwrite the
// leader's newer messages or resurrect a deleted session.
const session: ChatSessionMeta = {
sessionId: 'session-b',
userId: 'local',
characterId: 'default',
createdAt: 1,
updatedAt: 2,
}
getSessionMock.mockResolvedValue({
meta: { ...session, updatedAt: 1 },
messages: [{ id: 'stale', role: 'user', content: 'stale follower data' }],
})
const store = useChatSessionStore()
store.$patch({
sessionMessages: {
'session-b': [{ id: 'current', role: 'assistant', content: 'leader data', slices: [], tool_results: [] }],
},
sessionMetas: { 'session-b': session },
})
await expect(store.loadSession('session-b')).resolves.toBe(true)
expect(getSessionMock).not.toHaveBeenCalled()
expect(store.getSessionMessagesIfLoaded('session-b')?.map(message => message.id)).toEqual(['current'])
})
it('refreshes an already loaded session from IndexedDB for a completed remote stream', async () => {
const session: ChatSessionMeta = {
sessionId: 'session-b',
userId: 'local',
characterId: 'default',
createdAt: 1,
updatedAt: 2,
}
const store = useChatSessionStore()
store.applyRemoteSnapshot({
activeSessionId: 'session-b',
sessionMessages: { 'session-b': [{ id: 'system', role: 'system', content: 'prompt' }] },
sessionMetas: { 'session-b': session },
index: {
userId: 'local',
characters: {
default: {
activeSessionId: 'session-b',
sessions: { 'session-b': session },
},
},
},
})
getSessionMock.mockResolvedValue({
meta: session,
messages: [
{ id: 'system', role: 'system', content: 'prompt' },
{ id: 'assistant', role: 'assistant', content: 'complete answer', slices: [], tool_results: [] },
],
})
await expect(store.refreshSession('session-b')).resolves.toBe(true)
expect(getSessionMock).toHaveBeenCalledWith('session-b')
expect(store.getSessionMessages('session-b').map(message => message.id)).toEqual(['system', 'assistant'])
})
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3743121862
it('moves a follower away from a session removed by another window for Issue #2085', async () => {
// ROOT CAUSE:
//
// Synchronized deletion removed B's metadata, but activeSessionId is
// intentionally window-local. A follower that also selected B therefore
// kept an invalid selection until it manually chose another session.
const sessionA: ChatSessionMeta = {
sessionId: 'session-a',
userId: 'local',
characterId: 'default',
createdAt: 1,
updatedAt: 1,
}
const sessionB: ChatSessionMeta = {
sessionId: 'session-b',
userId: 'local',
characterId: 'default',
createdAt: 2,
updatedAt: 2,
}
const store = useChatSessionStore()
store.applyRemoteSnapshot({
activeSessionId: 'session-b',
sessionMessages: { 'session-a': [], 'session-b': [] },
sessionMetas: { 'session-a': sessionA, 'session-b': sessionB },
index: {
userId: 'local',
characters: {
default: {
activeSessionId: 'session-a',
sessions: { 'session-a': sessionA, 'session-b': sessionB },
},
},
},
})
await nextTick()
store.applyRemoteSnapshot({
activeSessionId: 'session-b',
sessionMessages: { 'session-a': [] },
sessionMetas: { 'session-a': sessionA },
index: {
userId: 'local',
characters: {
default: {
activeSessionId: 'session-a',
sessions: { 'session-a': sessionA },
},
},
},
})
await nextTick()
expect(store.activeSessionId).toBe('session-a')
})
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3743221033
it('waits for the leader replacement when every window loses its last session for Issue #2085', async () => {
// ROOT CAUSE:
//
// Every follower independently created a replacement when synchronized
// deletion temporarily left no metadata. Multiple windows could therefore
// turn one deletion into several empty chats before state converged.
const removedSession: ChatSessionMeta = {
sessionId: 'session-b',
userId: 'local',
characterId: 'default',
createdAt: 1,
updatedAt: 1,
}
const replacementSession: ChatSessionMeta = {
sessionId: 'session-c',
userId: 'local',
characterId: 'default',
createdAt: 2,
updatedAt: 2,
}
const store = useChatSessionStore()
store.applyRemoteSnapshot({
activeSessionId: 'session-b',
sessionMessages: { 'session-b': [] },
sessionMetas: { 'session-b': removedSession },
index: {
userId: 'local',
characters: {
default: {
activeSessionId: 'session-b',
sessions: { 'session-b': removedSession },
},
},
},
})
await nextTick()
saveSessionMock.mockClear()
store.applyRemoteSnapshot({
activeSessionId: 'session-b',
sessionMessages: {},
sessionMetas: {},
index: { userId: 'local', characters: {} },
})
await nextTick()
expect(saveSessionMock).not.toHaveBeenCalled()
expect(store.activeSessionId).toBe('session-b')
store.applyRemoteSnapshot({
activeSessionId: 'session-b',
sessionMessages: { 'session-c': [] },
sessionMetas: { 'session-c': replacementSession },
index: {
userId: 'local',
characters: {
default: {
activeSessionId: 'session-c',
sessions: { 'session-c': replacementSession },
},
},
},
})
await nextTick()
expect(store.activeSessionId).toBe('session-c')
})
it('deletes a message by its stable id from the specified session', async () => { it('deletes a message by its stable id from the specified session', async () => {
const store = useChatSessionStore() const store = useChatSessionStore()
store.applyRemoteSnapshot({ store.applyRemoteSnapshot({
@@ -415,11 +976,29 @@ describe('chat-session-store · synchronized data actions', () => {
expect(store.getSessionMessages('session-1').map(message => message.id)).toEqual(['keep']) expect(store.getSessionMessages('session-1').map(message => message.id)).toEqual(['keep'])
}) })
it('keeps the active session outside synchronized session state', () => { it('keeps window-local selection out of synchronized and persisted session state', async () => {
const store = useChatSessionStore() const store = useChatSessionStore()
store.activeSessionId = 'window-local-session' store.applyRemoteSnapshot({
activeSessionId: 'persisted-session',
index: {
userId: 'local',
characters: {
default: {
activeSessionId: 'persisted-session',
sessions: {},
},
},
},
sessionMessages: {
'window-local-session': [{ id: 'system', role: 'system', content: 'prompt' }],
},
sessionMetas: {},
})
await store.setActiveSession('window-local-session')
expect(store.activeSessionId).toBe('window-local-session') expect(store.activeSessionId).toBe('window-local-session')
expect(store.$state).not.toHaveProperty('activeSessionId') expect(store.$state).not.toHaveProperty('activeSessionId')
expect(store.getSnapshot().index?.characters.default?.activeSessionId).toBe('persisted-session')
}) })
}) })
+205 -103
View File
@@ -76,6 +76,7 @@ export const useChatSessionStore = defineStore('chat-session', () => {
const sessionMessages = ref<Record<string, ChatHistoryItem[]>>({}) const sessionMessages = ref<Record<string, ChatHistoryItem[]>>({})
const sessionMetas = ref<Record<string, ChatSessionMeta>>({}) const sessionMetas = ref<Record<string, ChatSessionMeta>>({})
const sessionGenerations = ref<Record<string, number>>({}) const sessionGenerations = ref<Record<string, number>>({})
/** Authority-owned session index replicated so each window can derive its local selection. */
const index = ref<ChatSessionsIndex | null>(null) const index = ref<ChatSessionsIndex | null>(null)
const ready = ref(false) const ready = ref(false)
@@ -92,7 +93,9 @@ export const useChatSessionStore = defineStore('chat-session', () => {
let persistQueue = Promise.resolve() let persistQueue = Promise.resolve()
const loadedSessions = new Set<string>() const loadedSessions = new Set<string>()
const loadingSessions = new Map<string, Promise<void>>() const staleSessions = new Set<string>()
const cloudHydratedSessions = new Set<string>()
const loadingSessions = new Map<string, Promise<boolean>>()
// Cloud sync state. The WS client is constructed lazily so anonymous // Cloud sync state. The WS client is constructed lazily so anonymous
// (`userId === 'local'`) users never open a socket. `cloudSyncReady` is a // (`userId === 'local'`) users never open a socket. `cloudSyncReady` is a
@@ -110,6 +113,7 @@ export const useChatSessionStore = defineStore('chat-session', () => {
let cloudMapper: CloudChatMapper | undefined let cloudMapper: CloudChatMapper | undefined
let cloudReconcileTask: Promise<void> | undefined let cloudReconcileTask: Promise<void> | undefined
let pendingReconcile = false let pendingReconcile = false
let ownsCloudSync = true
// Incremented on every teardown / user swap. Long-running reconcile IIFEs // Incremented on every teardown / user swap. Long-running reconcile IIFEs
// capture the epoch at start and bail after every await once it changes, // capture the epoch at start and bail after every await once it changes,
// so account-A mutations cannot land on account-B state after a sign-out. // so account-A mutations cannot land on account-B state after a sign-out.
@@ -327,7 +331,10 @@ export const useChatSessionStore = defineStore('chat-session', () => {
} }
/** Removes one message by stable id or by its current history index. */ /** Removes one message by stable id or by its current history index. */
function deleteMessage(payload: DeleteChatMessagePayload) { async function deleteMessage(payload: DeleteChatMessagePayload): Promise<void> {
if (!await loadSession(payload.sessionId))
throw new Error('Failed to load the target chat session')
const nextMessages = getSessionMessages(payload.sessionId).filter((message, messageIndex) => { const nextMessages = getSessionMessages(payload.sessionId).filter((message, messageIndex) => {
if (payload.messageId) if (payload.messageId)
return message.id !== payload.messageId return message.id !== payload.messageId
@@ -351,65 +358,83 @@ export const useChatSessionStore = defineStore('chat-session', () => {
* - `sessionId` exists either in `sessionMetas` or in IDB. * - `sessionId` exists either in `sessionMetas` or in IDB.
* *
* Returns: * Returns:
* - Resolves once the session is in memory. On IDB error, removes the id * - `true` when the session is in memory, or `false` when hydration failed.
* On IDB error, removes the id
* from the loading map so subsequent calls can retry rather than wedge * from the loading map so subsequent calls can retry rather than wedge
* on a stale promise. Errors are intentionally not rethrown the * on a stale promise. Errors are intentionally not rethrown the
* failing session is simply absent from local state and the next * failing session is simply absent from local state and the next
* loadSession call will retry. * loadSession call will retry.
*/ */
async function loadSession(sessionId: string) { async function loadSession(sessionId: string): Promise<boolean> {
if (loadedSessions.has(sessionId)) { const needsCloudHydration = () => {
return const meta = sessionMetas.value[sessionId]
return !!meta?.cloudChatId && !cloudHydratedSessions.has(sessionId)
}
if (loadedSessions.has(sessionId) && !staleSessions.has(sessionId) && !needsCloudHydration()) {
return true
}
// A synchronized snapshot already carries the authority's hydrated
// messages. Trust it instead of letting this follower merge an older IDB
// record and publish that stale full-store proposal back to the leader.
if (Object.hasOwn(sessionMessages.value, sessionId) && hasKnownSession(sessionId) && !staleSessions.has(sessionId) && !needsCloudHydration()) {
loadedSessions.add(sessionId)
return true
} }
if (loadingSessions.has(sessionId)) { if (loadingSessions.has(sessionId)) {
await loadingSessions.get(sessionId) return await loadingSessions.get(sessionId)!
return
} }
const loadPromise = (async () => { const loadPromise = (async () => {
try { try {
const stored = await chatSessionsRepo.getSession(sessionId) if (!loadedSessions.has(sessionId) || staleSessions.has(sessionId)) {
// Re-check existence: `deleteSession` (or `clearInMemoryState` on a const stored = await chatSessionsRepo.getSession(sessionId)
// user swap) may have run while we were awaiting IDB. Without this // Re-check existence after the IDB read. Deletion or an account
// guard, the post-await write resurrects the deleted entry and // change can remove this session while the read is pending.
// `loadedSessions.add` then short-circuits every future legitimate if (!sessionMetas.value[sessionId])
// load — locking the resurrection in. The drawer's batch return false
// loadSession + per-row trash button hits this race in production. if (staleSessions.has(sessionId) && !stored)
if (!sessionMetas.value[sessionId]) return false
return if (stored) {
if (stored) { const currentMessages = sessionMessages.value[sessionId] ?? []
const currentMessages = sessionMessages.value[sessionId] ?? [] const mergedMessages = mergeLoadedSessionMessages(stored.messages, currentMessages)
const mergedMessages = mergeLoadedSessionMessages(stored.messages, currentMessages)
sessionMetas.value[sessionId] = stored.meta sessionMetas.value[sessionId] = stored.meta
replaceSessionMessages(sessionId, mergedMessages, { persist: false }) replaceSessionMessages(sessionId, mergedMessages, { persist: false })
ensureGeneration(sessionId) ensureGeneration(sessionId)
if (mergedMessages !== stored.messages) if (mergedMessages !== stored.messages)
await persistSession(sessionId) await persistSession(sessionId)
}
staleSessions.delete(sessionId)
loadedSessions.add(sessionId)
if (activeSessionId.value === sessionId)
refreshActiveSessionSystemMessage()
} }
loadedSessions.add(sessionId)
if (activeSessionId.value === sessionId)
refreshActiveSessionSystemMessage()
// Cloud gap fill: when the session is mapped to a cloud chat, ask // Local and cloud hydration are separate. A failed cloud pull leaves
// the server for everything past our highest known seq. Best // the local view usable and keeps the next selection eligible to retry.
// effort — failures are logged inside pullCloudMessages and the if (needsCloudHydration())
// local view stays usable.
const meta = sessionMetas.value[sessionId]
if (meta?.cloudChatId)
await pullCloudMessages(sessionId) await pullCloudMessages(sessionId)
// Missing IDB payloads still need a valid canonical conversation.
// This action runs in the elected leader, so the initialized history
// is published once rather than independently by every follower.
ensureSession(sessionId)
return true
} }
catch (err) { catch (err) {
// Do NOT add to loadedSessions on failure — the next call should // Do NOT add to loadedSessions on failure — the next call should
// retry rather than fast-return on stale "already loaded" state. // retry rather than fast-return on stale "already loaded" state.
console.warn('[chat-session] loadSession failed for', sessionId, errorMessageFrom(err)) console.warn('[chat-session] loadSession failed for', sessionId, errorMessageFrom(err))
return false
} }
})() })()
loadingSessions.set(sessionId, loadPromise) loadingSessions.set(sessionId, loadPromise)
try { try {
await loadPromise return await loadPromise
} }
finally { finally {
// Always drain the loading map so a transient failure does not leave // Always drain the loading map so a transient failure does not leave
@@ -418,6 +443,13 @@ export const useChatSessionStore = defineStore('chat-session', () => {
} }
} }
/** Forces the next session load to merge the latest IndexedDB record into memory. */
async function refreshSession(sessionId: string): Promise<boolean> {
staleSessions.add(sessionId)
loadedSessions.delete(sessionId)
return loadSession(sessionId)
}
/** /**
* Mint a new session for `characterId`, optionally seeding it with messages * Mint a new session for `characterId`, optionally seeding it with messages
* and / or a title. Persists the new session and its index entry, then * and / or a title. Persists the new session and its index entry, then
@@ -493,26 +525,17 @@ export const useChatSessionStore = defineStore('chat-session', () => {
} }
/** /**
* Permanently remove a session from the local index + IDB and, when the * Permanently removes a session locally and tombstones its cloud identity.
* session is cloud-mapped and the user is signed in, soft-delete the
* server chat via `DELETE /api/v1/chats/:id`.
* *
* Use when: * Cloud failures are logged but do not restore the local session. A
* - The user explicitly chooses "delete" from the sessions drawer. * tombstone prevents the next reconcile from importing it again.
*
* Expects:
* - The caller does not need to pre-confirm: this method is destructive.
* When the deleted session is the active one, the store falls back to
* another session for the same character or creates a fresh one.
*
* Returns:
* - Resolves once both local state and (if applicable) the remote DELETE
* call have settled. Cloud failures are swallowed with a console.warn
* the local removal goes through either way so the user does not see
* a "ghost" session after the click. A tombstone is written so the
* reconcile `adopt` branch will not re-import the row on next login.
*/ */
async function deleteSession(sessionId: string) { async function deleteSession(sessionId: string) {
// Keep a monotonic tombstone in memory so queued and streaming sends that
// captured the previous generation cannot become current again after the
// session record is removed.
bumpSessionGeneration(sessionId)
const meta = sessionMetas.value[sessionId] const meta = sessionMetas.value[sessionId]
if (!meta) if (!meta)
return return
@@ -550,8 +573,9 @@ export const useChatSessionStore = defineStore('chat-session', () => {
// fire-and-forget. Persistence races now read the post-deletion state. // fire-and-forget. Persistence races now read the post-deletion state.
delete sessionMetas.value[sessionId] delete sessionMetas.value[sessionId]
delete sessionMessages.value[sessionId] delete sessionMessages.value[sessionId]
delete sessionGenerations.value[sessionId]
loadedSessions.delete(sessionId) loadedSessions.delete(sessionId)
staleSessions.delete(sessionId)
cloudHydratedSessions.delete(sessionId)
loadingSessions.delete(sessionId) loadingSessions.delete(sessionId)
if (index.value) { if (index.value) {
@@ -572,42 +596,55 @@ export const useChatSessionStore = defineStore('chat-session', () => {
await persistIndex() await persistIndex()
await refreshOutboxPendingCount() await refreshOutboxPendingCount()
if (cloudChatId && isCloudUser) { if (isCloudUser) {
const remoteChatId = cloudChatId ?? sessionId
// Tombstone first: even if the cloud DELETE never reaches the server // Tombstone first: even if the cloud DELETE never reaches the server
// (offline, transient 5xx), the next reconcile will see the cloudChatId // (offline, transient 5xx), the next reconcile will see the remote id
// here and skip the adopt branch — preventing the ghost-session bug // here and skip the adopt branch — preventing the ghost-session bug
// where the server still has the row and re-creates the local mapping. // where the server still has the row and re-creates the local mapping.
// The reconcile-driven `drainTombstones` retries failed DELETEs. // The reconcile-driven `drainTombstones` retries failed DELETEs.
await enqueuePersist(() => chatSessionsRepo.addTombstone(currentUserId, cloudChatId)) await enqueuePersist(() => chatSessionsRepo.addTombstone(currentUserId, remoteChatId))
getCloudMapper().deleteChat(cloudChatId).then( if (cloudChatId) {
async () => { getCloudMapper().deleteChat(cloudChatId).then(
// Server confirmed the delete; reconcile will not see this id again, async () => {
// so we can drop the tombstone. // Server confirmed the delete; reconcile will not see this id again,
await enqueuePersist(() => chatSessionsRepo.removeTombstones(currentUserId, [cloudChatId])) // so we can drop the tombstone.
}, await enqueuePersist(() => chatSessionsRepo.removeTombstones(currentUserId, [cloudChatId]))
(err) => { },
console.warn('[chat-sync] DELETE /api/v1/chats failed for', sessionId, errorMessageFrom(err)) (err) => {
}, console.warn('[chat-sync] DELETE /api/v1/chats failed for', sessionId, errorMessageFrom(err))
) },
)
}
} }
// If the deleted session was active, pick another for the same const characterIndex = index.value?.characters[characterId]
// character or mint a fresh one so the chat surface never lands on an const fallbackId = characterIndex
// empty void. ? Object.keys(characterIndex.sessions).find(id => sessionMetas.value[id])
if (wasActive) { : undefined
const characterIndex = index.value?.characters[characterId]
const fallbackId = characterIndex // Persisted character fallback is shared, but live selection is local to
? Object.keys(characterIndex.sessions).find(id => sessionMetas.value[id]) // the window that was displaying the deleted session.
: undefined if (fallbackId && characterIndex) {
if (fallbackId) { characterIndex.activeSessionId = fallbackId
if (wasActive) {
activeSessionId.value = fallbackId activeSessionId.value = fallbackId
if (characterIndex)
characterIndex.activeSessionId = fallbackId
await loadSession(fallbackId) await loadSession(fallbackId)
await persistIndex()
} }
else { await persistIndex()
await createSession(characterId, { setActive: true }) return
}
const replacementSessionId = await createSession(characterId, { setActive: wasActive })
if (!wasActive) {
// The synchronized leader may be displaying a different character, but
// this replacement is still the canonical fallback for the character
// whose final session was deleted. Persist that index choice without
// navigating the leader's window-local selection.
const replacementCharacterIndex = index.value?.characters[characterId]
if (replacementCharacterIndex) {
replacementCharacterIndex.activeSessionId = replacementSessionId
await persistIndex()
} }
} }
} }
@@ -644,10 +681,9 @@ export const useChatSessionStore = defineStore('chat-session', () => {
} }
activeSessionId.value = characterIndex.activeSessionId activeSessionId.value = characterIndex.activeSessionId
await loadSession(characterIndex.activeSessionId) // Use the public action so follower hydration is routed to the elected
if (isStaleEpoch()) // leader instead of becoming a stale full-state proposal.
return await useChatSessionStore().loadSession(characterIndex.activeSessionId)
ensureSession(characterIndex.activeSessionId)
})() })()
try { try {
await ensureActivePromise await ensureActivePromise
@@ -719,6 +755,7 @@ export const useChatSessionStore = defineStore('chat-session', () => {
messages: result.messages, messages: result.messages,
toSeq: result.seq, toSeq: result.seq,
}) })
cloudHydratedSessions.add(sessionId)
} }
catch (err) { catch (err) {
console.warn('[chat-sync] pullMessages failed for', sessionId, errorMessageFrom(err)) console.warn('[chat-sync] pullMessages failed for', sessionId, errorMessageFrom(err))
@@ -742,6 +779,8 @@ export const useChatSessionStore = defineStore('chat-session', () => {
* pass is scheduled in `finally` so catch-up pulls do not get lost. * pass is scheduled in `finally` so catch-up pulls do not get lost.
*/ */
async function reconcileCloudSessions(): Promise<void> { async function reconcileCloudSessions(): Promise<void> {
if (!ownsCloudSync)
return
if (cloudReconcileTask) { if (cloudReconcileTask) {
pendingReconcile = true pendingReconcile = true
return cloudReconcileTask return cloudReconcileTask
@@ -750,7 +789,7 @@ export const useChatSessionStore = defineStore('chat-session', () => {
const myEpoch = reconcileEpoch const myEpoch = reconcileEpoch
const isStaleEpoch = () => myEpoch !== reconcileEpoch const isStaleEpoch = () => myEpoch !== reconcileEpoch
cloudReconcileTask = (async () => { const reconcileTask = (async () => {
const currentUserId = getCurrentUserId() const currentUserId = getCurrentUserId()
if (currentUserId === 'local') { if (currentUserId === 'local') {
console.info('[chat-sync] reconcile skipped: anonymous user') console.info('[chat-sync] reconcile skipped: anonymous user')
@@ -907,6 +946,8 @@ export const useChatSessionStore = defineStore('chat-session', () => {
cloudSyncReady.value = true cloudSyncReady.value = true
})().finally(() => { })().finally(() => {
if (cloudReconcileTask !== reconcileTask)
return
cloudReconcileTask = undefined cloudReconcileTask = undefined
// A second 'open' event fired while we were running — schedule a // A second 'open' event fired while we were running — schedule a
// follow-up so its catch-up window is not lost. Skip if the epoch // follow-up so its catch-up window is not lost. Skip if the epoch
@@ -920,7 +961,8 @@ export const useChatSessionStore = defineStore('chat-session', () => {
} }
}) })
return cloudReconcileTask cloudReconcileTask = reconcileTask
return reconcileTask
} }
/** /**
@@ -929,6 +971,8 @@ export const useChatSessionStore = defineStore('chat-session', () => {
* from the auth `watch`. * from the auth `watch`.
*/ */
function ensureCloudWsClient() { function ensureCloudWsClient() {
if (!ownsCloudSync)
return
if (getCurrentUserId() === 'local') { if (getCurrentUserId() === 'local') {
console.info('[chat-sync] WS skipped: anonymous user') console.info('[chat-sync] WS skipped: anonymous user')
return return
@@ -985,6 +1029,20 @@ export const useChatSessionStore = defineStore('chat-session', () => {
cloudMapper = undefined cloudMapper = undefined
} }
/** Starts or stops cloud synchronization when this window gains or loses synchronized-store leadership. */
function setCloudSyncOwnership(owns: boolean) {
if (ownsCloudSync === owns)
return
ownsCloudSync = owns
if (!ready.value)
return
if (owns)
ensureCloudWsClient()
else if (wsClient)
teardownCloudWsClient()
}
/** /**
* Drop every in-memory session for the current user. Used when the auth * Drop every in-memory session for the current user. Used when the auth
* user changes we must NOT keep account A's sessions visible (or * user changes we must NOT keep account A's sessions visible (or
@@ -1002,6 +1060,8 @@ export const useChatSessionStore = defineStore('chat-session', () => {
sessionMetas.value = {} sessionMetas.value = {}
sessionGenerations.value = {} sessionGenerations.value = {}
loadedSessions.clear() loadedSessions.clear()
staleSessions.clear()
cloudHydratedSessions.clear()
loadingSessions.clear() loadingSessions.clear()
index.value = null index.value = null
activeSessionId.value = '' activeSessionId.value = ''
@@ -1231,7 +1291,8 @@ export const useChatSessionStore = defineStore('chat-session', () => {
// mid-send) before the WS even opens. The drain itself runs after // mid-send) before the WS even opens. The drain itself runs after
// reconcile completes, but the count is observable immediately. // reconcile completes, but the count is observable immediately.
await refreshOutboxPendingCount() await refreshOutboxPendingCount()
ensureCloudWsClient() if (ownsCloudSync)
ensureCloudWsClient()
})() })()
try { try {
@@ -1272,22 +1333,14 @@ export const useChatSessionStore = defineStore('chat-session', () => {
}, },
}) })
function setActiveSession(sessionId: string) { /** Selects and hydrates one conversation only in the current window. */
async function setActiveSession(sessionId: string) {
activeSessionId.value = sessionId activeSessionId.value = sessionId
const characterId = getCurrentCharacterId() if (ready.value)
const characterIndex = index.value?.characters[characterId] await useChatSessionStore().loadSession(sessionId)
if (characterIndex) { else if (!hasKnownSession(sessionId))
characterIndex.activeSessionId = sessionId
void persistIndex()
}
if (ready.value) {
void loadSession(sessionId)
}
else if (!hasKnownSession(sessionId)) {
ensureSession(sessionId) ensureSession(sessionId)
}
} }
function applyRemoteSnapshot(snapshot: { function applyRemoteSnapshot(snapshot: {
@@ -1306,6 +1359,8 @@ export const useChatSessionStore = defineStore('chat-session', () => {
Object.keys(snapshot.sessionMessages).map(sessionId => [sessionId, sessionGenerations.value[sessionId] ?? 0]), Object.keys(snapshot.sessionMessages).map(sessionId => [sessionId, sessionGenerations.value[sessionId] ?? 0]),
) )
loadedSessions.clear() loadedSessions.clear()
staleSessions.clear()
cloudHydratedSessions.clear()
for (const sessionId of Object.keys(snapshot.sessionMessages)) { for (const sessionId of Object.keys(snapshot.sessionMessages)) {
loadedSessions.add(sessionId) loadedSessions.add(sessionId)
} }
@@ -1349,6 +1404,8 @@ export const useChatSessionStore = defineStore('chat-session', () => {
sessionMetas.value = {} sessionMetas.value = {}
sessionGenerations.value = {} sessionGenerations.value = {}
loadedSessions.clear() loadedSessions.clear()
staleSessions.clear()
cloudHydratedSessions.clear()
loadingSessions.clear() loadingSessions.clear()
index.value = { index.value = {
@@ -1364,6 +1421,11 @@ export const useChatSessionStore = defineStore('chat-session', () => {
return sessionMessages.value[sessionId] ?? [] return sessionMessages.value[sessionId] ?? []
} }
/** Returns persisted/in-memory messages without creating an unloaded session fallback. */
function getSessionMessagesIfLoaded(sessionId: string) {
return sessionMessages.value[sessionId]
}
function getSessionGeneration(sessionId: string) { function getSessionGeneration(sessionId: string) {
ensureGeneration(sessionId) ensureGeneration(sessionId)
return sessionGenerations.value[sessionId] ?? 0 return sessionGenerations.value[sessionId] ?? 0
@@ -1432,6 +1494,8 @@ export const useChatSessionStore = defineStore('chat-session', () => {
sessionMetas.value = {} sessionMetas.value = {}
sessionGenerations.value = {} sessionGenerations.value = {}
loadedSessions.clear() loadedSessions.clear()
staleSessions.clear()
cloudHydratedSessions.clear()
loadingSessions.clear() loadingSessions.clear()
await enqueuePersist(() => chatSessionsRepo.saveIndex(cloneDeep(payload.index))) await enqueuePersist(() => chatSessionsRepo.saveIndex(cloneDeep(payload.index)))
@@ -1449,6 +1513,39 @@ export const useChatSessionStore = defineStore('chat-session', () => {
await ensureActiveSessionForCharacter() await ensureActiveSessionForCharacter()
} }
let lastActiveSessionMeta: ChatSessionMeta | undefined
// Session data is synchronized, but selection belongs to this window. If
// another window deletes the selected session, repair this window locally
// instead of leaving it pointed at an ID that can no longer hydrate.
watch([
activeSessionId,
() => Object.values(sessionMetas.value),
() => hasKnownSession(activeSessionId.value),
], ([sessionId, metas, isKnown]) => {
const meta = metas.find(candidate => candidate.sessionId === sessionId)
if (meta) {
lastActiveSessionMeta = meta
return
}
if (!sessionId || isKnown)
return
const characterId = lastActiveSessionMeta?.sessionId === sessionId
? lastActiveSessionMeta.characterId
: getCurrentCharacterId()
const fallbackSessionId = metas
.find(candidate => candidate.characterId === characterId && candidate.userId === getCurrentUserId())
?.sessionId
if (fallbackSessionId) {
void setActiveSession(fallbackSessionId)
}
// If no fallback exists yet, wait for the synchronized delete action.
// Its elected leader creates the single replacement; creating here in
// every follower would fan one deletion out into several empty chats.
})
watch([userId, activeCardId], () => { watch([userId, activeCardId], () => {
if (!ready.value) if (!ready.value)
return return
@@ -1470,7 +1567,7 @@ export const useChatSessionStore = defineStore('chat-session', () => {
watch(userId, (next) => { watch(userId, (next) => {
teardownCloudWsClient() teardownCloudWsClient()
clearInMemoryState() clearInMemoryState()
if (next && next !== 'local') { if (ownsCloudSync && next && next !== 'local') {
ensureCloudWsClient() ensureCloudWsClient()
} }
// Rehydrate for the new user. We trigger here (instead of relying on the // Rehydrate for the new user. We trigger here (instead of relying on the
@@ -1485,7 +1582,6 @@ export const useChatSessionStore = defineStore('chat-session', () => {
}) })
return { return {
ready,
isReady, isReady,
initialize, initialize,
@@ -1505,26 +1601,32 @@ export const useChatSessionStore = defineStore('chat-session', () => {
appendSessionMessage, appendSessionMessage,
persistSessionMessages, persistSessionMessages,
getSessionMessages, getSessionMessages,
getSessionMessagesIfLoaded,
sessionMessages, sessionMessages,
sessionMetas, sessionMetas,
getSessionGeneration, getSessionGeneration,
bumpSessionGeneration, bumpSessionGeneration,
getSessionGenerationValue, getSessionGenerationValue,
// Pinia can synchronize only refs returned by a setup store.
index,
forkSession, forkSession,
exportSessions, exportSessions,
importSessions, importSessions,
createSession, createSession,
loadSession, loadSession,
refreshSession,
deleteSession, deleteSession,
setCloudSyncOwnership,
cloudSyncReady, cloudSyncReady,
outboxPendingCount, outboxPendingCount,
pushMessageToCloud, pushMessageToCloud,
} }
}, { }, {
synced: { synced: {
actions: ['deleteMessage', 'importSessions'], actions: ['createSession', 'deleteMessage', 'importSessions', 'loadSession', 'refreshSession'],
state: true, state: true,
}, },
}) })
@@ -19,6 +19,7 @@ const beginStreamMock = vi.fn()
const appendStreamLiteralMock = vi.fn() const appendStreamLiteralMock = vi.fn()
const finalizeStreamMock = vi.fn() const finalizeStreamMock = vi.fn()
const resetStreamMock = vi.fn() const resetStreamMock = vi.fn()
const refreshSessionMock = vi.fn()
const serverSendMock = vi.fn() const serverSendMock = vi.fn()
const ensureConnectedMock = vi.fn().mockResolvedValue(undefined) const ensureConnectedMock = vi.fn().mockResolvedValue(undefined)
const onReconnectedMock = vi.fn(() => () => {}) const onReconnectedMock = vi.fn(() => () => {})
@@ -157,6 +158,7 @@ function createContextUpdateEvent(overrides: Record<string, unknown> = {}) {
} }
const chatOrchestratorMock = { const chatOrchestratorMock = {
activeSendSessionId: undefined as string | undefined,
sending: false, sending: false,
ingest: vi.fn(), ingest: vi.fn(),
@@ -237,6 +239,7 @@ vi.mock('../../chat/session-store', () => ({
return activeSessionIdRef.value return activeSessionIdRef.value
}, },
getSessionGenerationValue: () => currentGeneration, getSessionGenerationValue: () => currentGeneration,
refreshSession: (sessionId: string) => refreshSessionMock(sessionId),
}), }),
})) }))
@@ -291,6 +294,7 @@ describe('context bridge contract', () => {
appendStreamLiteralMock.mockReset() appendStreamLiteralMock.mockReset()
finalizeStreamMock.mockReset() finalizeStreamMock.mockReset()
resetStreamMock.mockReset() resetStreamMock.mockReset()
refreshSessionMock.mockReset().mockResolvedValue(true)
serverSendMock.mockReset() serverSendMock.mockReset()
ensureConnectedMock.mockClear() ensureConnectedMock.mockClear()
ensureConnectedMock.mockResolvedValue(undefined) ensureConnectedMock.mockResolvedValue(undefined)
@@ -304,6 +308,7 @@ describe('context bridge contract', () => {
activeProviderRef.value = null activeProviderRef.value = null
activeModelRef.value = null activeModelRef.value = null
activeSessionIdRef.value = 'session-1' activeSessionIdRef.value = 'session-1'
chatOrchestratorMock.activeSendSessionId = undefined
currentGeneration = 7 currentGeneration = 7
chatOrchestratorMock.sending = false chatOrchestratorMock.sending = false
@@ -325,10 +330,6 @@ describe('context bridge contract', () => {
closeTestChannels() closeTestChannels()
}) })
/**
* @example
* Broadcast context updates record store-ingested with core result fields.
*/
it('records core ingest result for broadcast context updates', async () => { it('records core ingest result for broadcast context updates', async () => {
chatContextIngestMock.mockReturnValueOnce({ chatContextIngestMock.mockReturnValueOnce({
sourceKey: 'weather:station-1', sourceKey: 'weather:station-1',
@@ -361,10 +362,6 @@ describe('context bridge contract', () => {
await store.dispose() await store.dispose()
}) })
/**
* @example
* Server context updates record store-ingested before broadcast-posted.
*/
it('records core ingest result for server context updates before broadcasting', async () => { it('records core ingest result for server context updates before broadcasting', async () => {
chatContextIngestMock.mockReturnValueOnce({ chatContextIngestMock.mockReturnValueOnce({
sourceKey: 'weather:station-1', sourceKey: 'weather:station-1',
@@ -399,10 +396,6 @@ describe('context bridge contract', () => {
await store.dispose() await store.dispose()
}) })
/**
* @example
* Input context updates record store-ingested and stay in chat input payload.
*/
it('records core ingest result for input context updates and forwards accepted updates', async () => { it('records core ingest result for input context updates and forwards accepted updates', async () => {
chatContextIngestMock.mockReturnValueOnce({ chatContextIngestMock.mockReturnValueOnce({
sourceKey: 'weather:station-1', sourceKey: 'weather:station-1',
@@ -453,10 +446,6 @@ describe('context bridge contract', () => {
await store.dispose() await store.dispose()
}) })
/**
* @example
* Broadcast context ingest failures record store-ingest-rejected instead of escaping.
*/
it('records rejected lifecycle for broadcast ingest failures without interrupting the watcher', async () => { it('records rejected lifecycle for broadcast ingest failures without interrupting the watcher', async () => {
chatContextIngestMock.mockImplementationOnce(() => { chatContextIngestMock.mockImplementationOnce(() => {
throw new Error('Cannot clone broadcast context') throw new Error('Cannot clone broadcast context')
@@ -485,10 +474,6 @@ describe('context bridge contract', () => {
await store.dispose() await store.dispose()
}) })
/**
* @example
* Server context ingest failures are not rebroadcast.
*/
it('records rejected lifecycle and skips broadcast when server context ingest fails', async () => { it('records rejected lifecycle and skips broadcast when server context ingest fails', async () => {
chatContextIngestMock.mockImplementationOnce(() => { chatContextIngestMock.mockImplementationOnce(() => {
throw new Error('Cannot clone server context') throw new Error('Cannot clone server context')
@@ -520,10 +505,6 @@ describe('context bridge contract', () => {
await store.dispose() await store.dispose()
}) })
/**
* @example
* Input context ingest failures drop only the failed context update.
*/
it('records rejected lifecycle and continues text ingestion when input context ingest fails', async () => { it('records rejected lifecycle and continues text ingestion when input context ingest fails', async () => {
chatContextIngestMock.mockImplementationOnce(() => { chatContextIngestMock.mockImplementationOnce(() => {
throw new Error('Cannot clone input context') throw new Error('Cannot clone input context')
@@ -562,7 +543,14 @@ describe('context bridge contract', () => {
await store.dispose() await store.dispose()
}) })
it('replays remote stream lifecycle into sending and stream store APIs', async () => { // https://github.com/moeru-ai/airi/pull/2086#discussion_r3743366445
it('keeps a remote stream visible locally without publishing chat authority state for Issue #2085', async () => {
// ROOT CAUSE:
//
// Stage Pocket uses plain Pinia, so remote stream tokens update only the
// local stream store. The history requires a sending flag, but writing
// that flag to the synchronized chat store would let a follower overwrite
// the elected authority's stream snapshot.
const store = useContextBridgeStore() const store = useContextBridgeStore()
await store.initialize() await store.initialize()
const streamSender = createTestChannel(CHAT_STREAM_CHANNEL_NAME) const streamSender = createTestChannel(CHAT_STREAM_CHANNEL_NAME)
@@ -574,18 +562,19 @@ describe('context bridge contract', () => {
composedMessage: [], composedMessage: [],
} satisfies ChatStreamEventContext } satisfies ChatStreamEventContext
streamSender.postMessage({ type: 'before-send', message: 'ping', sessionId: 'remote-session', context }) streamSender.postMessage({ type: 'before-send', message: 'ping', sessionId: 'session-1', context })
await vi.waitFor(() => { await vi.waitFor(() => {
expect(chatOrchestratorMock.sending).toBe(true)
expect(beginStreamMock).toHaveBeenCalledWith('turn-1') expect(beginStreamMock).toHaveBeenCalledWith('turn-1')
}) })
expect(chatOrchestratorMock.sending).toBe(false)
expect(store.isReceivingRemoteStream).toBe(true)
streamSender.postMessage({ type: 'token-literal', literal: 'hello', sessionId: 'remote-session', context }) streamSender.postMessage({ type: 'token-literal', literal: 'hello', sessionId: 'session-1', context })
await vi.waitFor(() => { await vi.waitFor(() => {
expect(appendStreamLiteralMock).toHaveBeenCalledWith('hello') expect(appendStreamLiteralMock).toHaveBeenCalledWith('hello')
}) })
streamSender.postMessage({ type: 'assistant-end', message: 'final answer', sessionId: 'remote-session', context }) streamSender.postMessage({ type: 'assistant-end', message: 'final answer', sessionId: 'session-1', context })
await vi.waitFor(() => { await vi.waitFor(() => {
expect(resetStreamMock).toHaveBeenCalledTimes(1) expect(resetStreamMock).toHaveBeenCalledTimes(1)
}) })
@@ -594,6 +583,7 @@ describe('context bridge contract', () => {
// to avoid corrupting history by persisting a duplicate assistant message. // to avoid corrupting history by persisting a duplicate assistant message.
expect(finalizeStreamMock).not.toHaveBeenCalled() expect(finalizeStreamMock).not.toHaveBeenCalled()
expect(chatOrchestratorMock.sending).toBe(false) expect(chatOrchestratorMock.sending).toBe(false)
expect(store.isReceivingRemoteStream).toBe(false)
await store.dispose() await store.dispose()
}) })
@@ -624,6 +614,166 @@ describe('context bridge contract', () => {
await store.dispose() await store.dispose()
}) })
it('labels outbound stream events with the session that owns the send', async () => {
const outgoingStreamMessages = collectChannelMessages<{ sessionId: string }>(CHAT_STREAM_CHANNEL_NAME)
const store = useContextBridgeStore()
await store.initialize()
const context = {
turnId: 'turn-1',
message: { role: 'user', content: 'ping' },
contexts: {},
composedMessage: [],
} satisfies ChatStreamEventContext
chatOrchestratorMock.activeSendSessionId = 'session-a'
activeSessionIdRef.value = 'session-b'
await chatOrchestratorMock.emitTokenLiteralHooks('session A token', context)
await vi.waitFor(() => expect(outgoingStreamMessages).toHaveLength(1))
expect(outgoingStreamMessages[0]?.sessionId).toBe('session-a')
await store.dispose()
})
it('clears remote stream visibility when an end hook rejects', async () => {
const store = useContextBridgeStore()
await store.initialize()
const streamSender = createTestChannel(CHAT_STREAM_CHANNEL_NAME)
const context = {
turnId: 'turn-1',
message: { role: 'user', content: 'ping' },
contexts: {},
composedMessage: [],
} satisfies ChatStreamEventContext
chatOrchestratorMock.onStreamEnd(async () => {
throw new Error('end hook failed')
})
vi.spyOn(console, 'error').mockImplementation(() => {})
streamSender.postMessage({ type: 'before-send', message: 'ping', sessionId: 'session-1', context })
await vi.waitFor(() => expect(store.isReceivingRemoteStream).toBe(true))
streamSender.postMessage({ type: 'stream-end', sessionId: 'session-1', context })
await vi.waitFor(() => expect(store.isReceivingRemoteStream).toBe(false))
expect(resetStreamMock).toHaveBeenCalledTimes(1)
await store.dispose()
})
it('ignores stream events that do not match the active remote session', async () => {
const store = useContextBridgeStore()
await store.initialize()
const streamSender = createTestChannel(CHAT_STREAM_CHANNEL_NAME)
const context = {
turnId: 'turn-1',
message: { role: 'user', content: 'ping' },
contexts: {},
composedMessage: [],
} satisfies ChatStreamEventContext
streamSender.postMessage({ type: 'before-send', message: 'ping', sessionId: 'session-1', context })
await vi.waitFor(() => expect(store.isReceivingRemoteStream).toBe(true))
streamSender.postMessage({ type: 'token-literal', literal: 'foreign token', sessionId: 'session-2', context })
streamSender.postMessage({ type: 'stream-end', sessionId: 'session-2', context })
await waitForBroadcastDelivery()
expect(appendStreamLiteralMock).not.toHaveBeenCalledWith('foreign token')
expect(store.isReceivingRemoteStream).toBe(true)
streamSender.postMessage({ type: 'stream-end', sessionId: 'session-1', context })
await vi.waitFor(() => expect(store.isReceivingRemoteStream).toBe(false))
await store.dispose()
})
it('does not replace the foreground stream for an inactive remote session', async () => {
const store = useContextBridgeStore()
await store.initialize()
const streamSender = createTestChannel(CHAT_STREAM_CHANNEL_NAME)
const context = {
turnId: 'turn-2',
message: { role: 'user', content: 'background ping' },
contexts: {},
composedMessage: [],
} satisfies ChatStreamEventContext
streamSender.postMessage({ type: 'before-send', message: 'background ping', sessionId: 'session-2', context })
await waitForBroadcastDelivery()
expect(beginStreamMock).not.toHaveBeenCalled()
expect(store.isReceivingRemoteStream).toBe(false)
streamSender.postMessage({ type: 'stream-end', sessionId: 'session-2', context })
await waitForBroadcastDelivery()
expect(resetStreamMock).not.toHaveBeenCalled()
await store.dispose()
})
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3755585351
it('keeps remote literals received before a mid-stream session switch for Issue #2085', async () => {
// ROOT CAUSE:
//
// The bridge discarded literals while their session was not selected.
// Selecting that session during the stream showed only later literals.
activeSessionIdRef.value = 'session-2'
const store = useContextBridgeStore()
await store.initialize()
const streamSender = createTestChannel(CHAT_STREAM_CHANNEL_NAME)
const context = {
turnId: 'turn-3',
message: { role: 'user', content: 'background ping' },
contexts: {},
composedMessage: [],
} satisfies ChatStreamEventContext
streamSender.postMessage({ type: 'before-send', message: 'background ping', sessionId: 'session-1', context })
streamSender.postMessage({ type: 'token-literal', literal: 'first half ', sessionId: 'session-1', context })
await waitForBroadcastDelivery()
expect(beginStreamMock).not.toHaveBeenCalled()
expect(appendStreamLiteralMock).not.toHaveBeenCalled()
activeSessionIdRef.value = 'session-1'
streamSender.postMessage({ type: 'token-literal', literal: 'second half', sessionId: 'session-1', context })
await vi.waitFor(() => expect(appendStreamLiteralMock).toHaveBeenCalledTimes(2))
expect(beginStreamMock).toHaveBeenCalledWith('turn-3')
expect(appendStreamLiteralMock).toHaveBeenNthCalledWith(1, 'first half ')
expect(appendStreamLiteralMock).toHaveBeenNthCalledWith(2, 'second half')
await store.dispose()
})
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3755711154
it('reloads a completed remote stream when its Pocket session becomes active for Issue #2085', async () => {
// ROOT CAUSE:
//
// A plain-Pinia Pocket tab discarded a completed background stream.
// Its loaded-session cache then prevented a later IndexedDB refresh.
activeSessionIdRef.value = 'session-1'
const store = useContextBridgeStore()
await store.initialize()
const streamSender = createTestChannel(CHAT_STREAM_CHANNEL_NAME)
const context = {
turnId: 'turn-4',
message: { role: 'user', content: 'background ping' },
contexts: {},
composedMessage: [],
} satisfies ChatStreamEventContext
streamSender.postMessage({ type: 'before-send', message: 'background ping', sessionId: 'session-2', context })
streamSender.postMessage({ type: 'token-literal', literal: 'complete answer', sessionId: 'session-2', context })
streamSender.postMessage({ type: 'stream-end', sessionId: 'session-2', context })
streamSender.postMessage({ type: 'assistant-end', message: 'complete answer', sessionId: 'session-2', context })
await waitForBroadcastDelivery()
expect(refreshSessionMock).not.toHaveBeenCalled()
expect(resetStreamMock).not.toHaveBeenCalled()
activeSessionIdRef.value = 'session-2'
await vi.waitFor(() => expect(refreshSessionMock).toHaveBeenCalledWith('session-2'))
await vi.waitFor(() => expect(resetStreamMock).toHaveBeenCalledTimes(1))
expect(beginStreamMock).toHaveBeenCalledWith('turn-4')
expect(appendStreamLiteralMock).toHaveBeenCalledWith('complete answer')
expect(store.isReceivingRemoteStream).toBe(false)
await store.dispose()
})
it('ignores remote literal and end events when generation guard is stale', async () => { it('ignores remote literal and end events when generation guard is stale', async () => {
const store = useContextBridgeStore() const store = useContextBridgeStore()
await store.initialize() await store.initialize()
@@ -636,21 +786,22 @@ describe('context bridge contract', () => {
composedMessage: [], composedMessage: [],
} satisfies ChatStreamEventContext } satisfies ChatStreamEventContext
streamSender.postMessage({ type: 'before-send', message: 'ping', sessionId: 'remote-session', context }) streamSender.postMessage({ type: 'before-send', message: 'ping', sessionId: 'session-1', context })
await vi.waitFor(() => { await vi.waitFor(() => {
expect(beginStreamMock).toHaveBeenCalledWith('turn-1') expect(beginStreamMock).toHaveBeenCalledWith('turn-1')
}) })
currentGeneration = 8 currentGeneration = 8
streamSender.postMessage({ type: 'token-literal', literal: 'stale-literal', sessionId: 'remote-session', context }) streamSender.postMessage({ type: 'token-literal', literal: 'stale-literal', sessionId: 'session-1', context })
await waitForBroadcastDelivery() await waitForBroadcastDelivery()
streamSender.postMessage({ type: 'stream-end', sessionId: 'remote-session', context }) streamSender.postMessage({ type: 'stream-end', sessionId: 'session-1', context })
await waitForBroadcastDelivery() await waitForBroadcastDelivery()
expect(appendStreamLiteralMock).not.toHaveBeenCalledWith('stale-literal') expect(appendStreamLiteralMock).not.toHaveBeenCalledWith('stale-literal')
expect(finalizeStreamMock).not.toHaveBeenCalled() expect(finalizeStreamMock).not.toHaveBeenCalled()
expect(chatOrchestratorMock.sending).toBe(true) expect(chatOrchestratorMock.sending).toBe(false)
expect(store.isReceivingRemoteStream).toBe(false)
await store.dispose() await store.dispose()
}) })
@@ -12,7 +12,7 @@ import { useBroadcastChannel } from '@vueuse/core'
import { Mutex } from 'es-toolkit' import { Mutex } from 'es-toolkit'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
import { defineStore, storeToRefs } from 'pinia' import { defineStore, storeToRefs } from 'pinia'
import { ref, toRaw, watch } from 'vue' import { computed, ref, shallowRef, toRaw, watch } from 'vue'
import { getEventSourceKey, getMetadataSourceLabel } from '../../../utils/event-source' import { getEventSourceKey, getMetadataSourceLabel } from '../../../utils/event-source'
import { useLlmStreamingControlStore } from '../../ai/chat-llm/streaming-control' import { useLlmStreamingControlStore } from '../../ai/chat-llm/streaming-control'
@@ -89,10 +89,61 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
const { post: postSparkNotifyBridgeMessage, data: incomingSparkNotifyBridgeMessage } = useBroadcastChannel<SparkNotifyBridgeMessage, SparkNotifyBridgeMessage>({ name: SPARK_NOTIFY_BRIDGE_CHANNEL_NAME }) const { post: postSparkNotifyBridgeMessage, data: incomingSparkNotifyBridgeMessage } = useBroadcastChannel<SparkNotifyBridgeMessage, SparkNotifyBridgeMessage>({ name: SPARK_NOTIFY_BRIDGE_CHANNEL_NAME })
const disposeHookFns = ref<Array<() => void>>([]) const disposeHookFns = ref<Array<() => void>>([])
let remoteStreamGuard: { sessionId: string, generation: number } | null = null // Remote stream data belongs to this renderer only. Keeping its visibility
// outside the synchronized chat store prevents a follower from publishing
// its local runtime state over the elected authority's snapshot.
const remoteStreamGuard = shallowRef<{
sessionId: string
generation: number
turnId: string
started: boolean
completed: boolean
refreshing: boolean
pendingLiterals: string[]
}>()
const isReceivingRemoteStream = computed(() => remoteStreamGuard.value?.sessionId === chatSession.activeSessionId)
let contextChannel: ReturnType<typeof createContextChannel> | undefined let contextChannel: ReturnType<typeof createContextChannel> | undefined
let initialized = false let initialized = false
function presentRemoteStreamIfActive() {
const guard = remoteStreamGuard.value
if (!guard || guard.sessionId !== chatSession.activeSessionId)
return false
if (chatSession.getSessionGenerationValue(guard.sessionId) !== guard.generation)
return false
if (!guard.started) {
guard.started = true
chatStream.beginStream(guard.turnId)
}
for (const literal of guard.pendingLiterals.splice(0))
chatStream.appendStreamLiteral(literal)
if (guard.completed && !guard.refreshing)
void refreshCompletedRemoteStream(guard)
return true
}
async function refreshCompletedRemoteStream(guard: NonNullable<typeof remoteStreamGuard.value>) {
guard.refreshing = true
let loaded = false
try {
loaded = await chatSession.refreshSession(guard.sessionId)
}
catch (error) {
console.warn('[context-bridge] Failed to refresh completed remote stream:', errorMessageFrom(error))
}
if (remoteStreamGuard.value !== guard)
return
guard.refreshing = false
if (!loaded || guard.sessionId !== chatSession.activeSessionId)
return
if (chatSession.getSessionGenerationValue(guard.sessionId) !== guard.generation)
return
chatStream.resetStream()
remoteStreamGuard.value = undefined
}
function recordContextIngestRejected(options: { function recordContextIngestRejected(options: {
channel: 'server' | 'broadcast' | 'input' channel: 'server' | 'broadcast' | 'input'
contextMessage: ContextMessage contextMessage: ContextMessage
@@ -699,49 +750,49 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
if (isProcessingRemoteStream) if (isProcessingRemoteStream)
return return
await contextChannel?.emitStream({ type: 'before-compose', message, sessionId: chatSession.activeSessionId, context: structuredClone(normalizeContextSnapshot(context)) }) await contextChannel?.emitStream({ type: 'before-compose', message, sessionId: chatOrchestrator.activeSendSessionId ?? chatSession.activeSessionId, context: structuredClone(normalizeContextSnapshot(context)) })
}), }),
chatOrchestrator.onAfterMessageComposed(async (message, context) => { chatOrchestrator.onAfterMessageComposed(async (message, context) => {
if (isProcessingRemoteStream) if (isProcessingRemoteStream)
return return
await contextChannel?.emitStream({ type: 'after-compose', message, sessionId: chatSession.activeSessionId, context: structuredClone(normalizeContextSnapshot(context)) }) await contextChannel?.emitStream({ type: 'after-compose', message, sessionId: chatOrchestrator.activeSendSessionId ?? chatSession.activeSessionId, context: structuredClone(normalizeContextSnapshot(context)) })
}), }),
chatOrchestrator.onBeforeSend(async (message, context) => { chatOrchestrator.onBeforeSend(async (message, context) => {
if (isProcessingRemoteStream) if (isProcessingRemoteStream)
return return
await contextChannel?.emitStream({ type: 'before-send', message, sessionId: chatSession.activeSessionId, context: structuredClone(normalizeContextSnapshot(context)) }) await contextChannel?.emitStream({ type: 'before-send', message, sessionId: chatOrchestrator.activeSendSessionId ?? chatSession.activeSessionId, context: structuredClone(normalizeContextSnapshot(context)) })
}), }),
chatOrchestrator.onAfterSend(async (message, context) => { chatOrchestrator.onAfterSend(async (message, context) => {
if (isProcessingRemoteStream) if (isProcessingRemoteStream)
return return
await contextChannel?.emitStream({ type: 'after-send', message, sessionId: chatSession.activeSessionId, context: structuredClone(normalizeContextSnapshot(context)) }) await contextChannel?.emitStream({ type: 'after-send', message, sessionId: chatOrchestrator.activeSendSessionId ?? chatSession.activeSessionId, context: structuredClone(normalizeContextSnapshot(context)) })
}), }),
chatOrchestrator.onTokenLiteral(async (literal, context) => { chatOrchestrator.onTokenLiteral(async (literal, context) => {
if (isProcessingRemoteStream) if (isProcessingRemoteStream)
return return
await contextChannel?.emitStream({ type: 'token-literal', literal, sessionId: chatSession.activeSessionId, context: structuredClone(normalizeContextSnapshot(context)) }) await contextChannel?.emitStream({ type: 'token-literal', literal, sessionId: chatOrchestrator.activeSendSessionId ?? chatSession.activeSessionId, context: structuredClone(normalizeContextSnapshot(context)) })
}), }),
chatOrchestrator.onTokenSpecial(async (special, context) => { chatOrchestrator.onTokenSpecial(async (special, context) => {
if (isProcessingRemoteStream) if (isProcessingRemoteStream)
return return
await contextChannel?.emitStream({ type: 'token-special', special, sessionId: chatSession.activeSessionId, context: structuredClone(normalizeContextSnapshot(context)) }) await contextChannel?.emitStream({ type: 'token-special', special, sessionId: chatOrchestrator.activeSendSessionId ?? chatSession.activeSessionId, context: structuredClone(normalizeContextSnapshot(context)) })
}), }),
chatOrchestrator.onStreamEnd(async (context) => { chatOrchestrator.onStreamEnd(async (context) => {
if (isProcessingRemoteStream) if (isProcessingRemoteStream)
return return
await contextChannel?.emitStream({ type: 'stream-end', sessionId: chatSession.activeSessionId, context: structuredClone(normalizeContextSnapshot(context)) }) await contextChannel?.emitStream({ type: 'stream-end', sessionId: chatOrchestrator.activeSendSessionId ?? chatSession.activeSessionId, context: structuredClone(normalizeContextSnapshot(context)) })
}), }),
chatOrchestrator.onAssistantResponseEnd(async (message, context) => { chatOrchestrator.onAssistantResponseEnd(async (message, context) => {
if (isProcessingRemoteStream) if (isProcessingRemoteStream)
return return
await contextChannel?.emitStream({ type: 'assistant-end', message, sessionId: chatSession.activeSessionId, context: structuredClone(normalizeContextSnapshot(context)) }) await contextChannel?.emitStream({ type: 'assistant-end', message, sessionId: chatOrchestrator.activeSendSessionId ?? chatSession.activeSessionId, context: structuredClone(normalizeContextSnapshot(context)) })
}), }),
chatOrchestrator.onAssistantMessage(async (message, _messageText, context) => { chatOrchestrator.onAssistantMessage(async (message, _messageText, context) => {
@@ -794,7 +845,8 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
isProcessingRemoteStream = true isProcessingRemoteStream = true
try { try {
// Use the receiver's active session to avoid clobbering chat state when events come from other windows/devtools. // Remote UI state is correlated by session and generation. The
// receiver never persists these mirrored stream events.
switch (event.type) { switch (event.type) {
case 'before-compose': case 'before-compose':
await chatOrchestrator.emitBeforeMessageComposedHooks(event.message, event.context) await chatOrchestrator.emitBeforeMessageComposedHooks(event.message, event.context)
@@ -804,58 +856,99 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
break break
case 'before-send': case 'before-send':
await chatOrchestrator.emitBeforeSendHooks(event.message, event.context) await chatOrchestrator.emitBeforeSendHooks(event.message, event.context)
remoteStreamGuard = { remoteStreamGuard.value = {
sessionId: chatSession.activeSessionId, sessionId: event.sessionId,
generation: chatSession.getSessionGenerationValue(chatSession.activeSessionId), generation: chatSession.getSessionGenerationValue(event.sessionId),
turnId: event.context.turnId,
started: false,
completed: false,
refreshing: false,
pendingLiterals: [],
} }
chatOrchestrator.sending = true presentRemoteStreamIfActive()
chatStream.beginStream(event.context.turnId)
break break
case 'after-send': case 'after-send':
await chatOrchestrator.emitAfterSendHooks(event.message, event.context) await chatOrchestrator.emitAfterSendHooks(event.message, event.context)
break break
case 'token-literal': case 'token-literal':
if (!remoteStreamGuard) if (!remoteStreamGuard.value)
return return
if (remoteStreamGuard.sessionId !== chatSession.activeSessionId) if (event.sessionId !== remoteStreamGuard.value.sessionId)
return return
if (chatSession.getSessionGenerationValue(remoteStreamGuard.sessionId) !== remoteStreamGuard.generation) if (chatSession.getSessionGenerationValue(remoteStreamGuard.value.sessionId) !== remoteStreamGuard.value.generation)
return
remoteStreamGuard.value.pendingLiterals.push(event.literal)
if (!presentRemoteStreamIfActive())
return return
chatStream.appendStreamLiteral(event.literal)
await chatOrchestrator.emitTokenLiteralHooks(event.literal, event.context) await chatOrchestrator.emitTokenLiteralHooks(event.literal, event.context)
break break
case 'token-special': case 'token-special':
if (!remoteStreamGuard.value || event.sessionId !== remoteStreamGuard.value.sessionId)
return
if (remoteStreamGuard.value.sessionId !== chatSession.activeSessionId)
return
if (chatSession.getSessionGenerationValue(remoteStreamGuard.value.sessionId) !== remoteStreamGuard.value.generation)
return
await chatOrchestrator.emitTokenSpecialHooks(event.special, event.context) await chatOrchestrator.emitTokenSpecialHooks(event.special, event.context)
break break
case 'stream-end': case 'stream-end':
if (!remoteStreamGuard) if (!remoteStreamGuard.value)
break break
if (remoteStreamGuard.sessionId !== chatSession.activeSessionId) {
break const guard = remoteStreamGuard.value
if (chatSession.getSessionGenerationValue(remoteStreamGuard.sessionId) !== remoteStreamGuard.generation) if (event.sessionId !== guard.sessionId)
break break
await chatOrchestrator.emitStreamEndHooks(event.context) if (guard.sessionId !== chatSession.activeSessionId
// NOTICE: Remote stream events are mirrored across renderer windows for UI feedback only. && chatSession.getSessionGenerationValue(guard.sessionId) === guard.generation) {
// Persisting them here would append assistant messages into the receiver's local session break
// without the corresponding user message, corrupting IndexedDB history across windows. }
chatStream.resetStream() try {
chatOrchestrator.sending = false if (guard.sessionId === chatSession.activeSessionId
remoteStreamGuard = null && chatSession.getSessionGenerationValue(guard.sessionId) === guard.generation) {
await chatOrchestrator.emitStreamEndHooks(event.context)
}
}
finally {
if (remoteStreamGuard.value === guard) {
if (guard.started
&& guard.sessionId === chatSession.activeSessionId
&& chatSession.getSessionGenerationValue(guard.sessionId) === guard.generation) {
chatStream.resetStream()
}
remoteStreamGuard.value = undefined
}
}
}
break break
case 'assistant-end': case 'assistant-end':
if (!remoteStreamGuard) if (!remoteStreamGuard.value)
break break
if (remoteStreamGuard.sessionId !== chatSession.activeSessionId) {
break const guard = remoteStreamGuard.value
if (chatSession.getSessionGenerationValue(remoteStreamGuard.sessionId) !== remoteStreamGuard.generation) if (event.sessionId !== guard.sessionId)
break break
await chatOrchestrator.emitAssistantResponseEndHooks(event.message, event.context) if (guard.sessionId !== chatSession.activeSessionId
// NOTICE: The originating renderer already persists the final assistant message. && chatSession.getSessionGenerationValue(guard.sessionId) === guard.generation) {
// Receiver windows must not write it again, or they can overwrite the same session guard.completed = true
// with assistant-only history when their local session state is stale. break
chatStream.resetStream() }
chatOrchestrator.sending = false try {
remoteStreamGuard = null if (guard.sessionId === chatSession.activeSessionId
&& chatSession.getSessionGenerationValue(guard.sessionId) === guard.generation) {
await chatOrchestrator.emitAssistantResponseEndHooks(event.message, event.context)
}
}
finally {
if (remoteStreamGuard.value === guard) {
if (guard.started
&& guard.sessionId === chatSession.activeSessionId
&& chatSession.getSessionGenerationValue(guard.sessionId) === guard.generation) {
chatStream.resetStream()
}
remoteStreamGuard.value = undefined
}
}
}
break break
} }
} }
@@ -864,6 +957,11 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
} }
}) })
disposeHookFns.value.push(stopIncomingStreamWatch) disposeHookFns.value.push(stopIncomingStreamWatch)
disposeHookFns.value.push(watch(
() => chatSession.activeSessionId,
() => presentRemoteStreamIfActive(),
{ flush: 'sync' },
))
initialized = true initialized = true
} }
catch (error) { catch (error) {
@@ -905,7 +1003,7 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
contextChannel = undefined contextChannel = undefined
initialized = false initialized = false
remoteStreamGuard = null remoteStreamGuard.value = undefined
for (const [requestId, waiter] of sparkNotifyBridgeWaiters) { for (const [requestId, waiter] of sparkNotifyBridgeWaiters) {
if (waiter.timeout) if (waiter.timeout)
@@ -925,6 +1023,7 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
dispose, dispose,
dispatchSparkNotifyReaction, dispatchSparkNotifyReaction,
dispatchSparkNotifyPerformance, dispatchSparkNotifyPerformance,
isReceivingRemoteStream,
setSparkNotifyHostRole, setSparkNotifyHostRole,
} }
}) })
+1 -1
View File
@@ -6,7 +6,7 @@ export default defineConfig({
'server/apps/auth', 'server/apps/auth',
'server/apps/api', 'server/apps/api',
'apps/ui-server-auth', 'apps/ui-server-auth',
'apps/stage-tamagotchi', 'apps/stage-tamagotchi/vitest.node.config.ts',
'packages/cap-vite', 'packages/cap-vite',
'packages/ccc', 'packages/ccc',
'packages/core-agent', 'packages/core-agent',