diff --git a/src/hooks/useChatSession.test.tsx b/src/hooks/useChatSession.test.tsx index 922f0b9b5..d2e2ef2c0 100644 --- a/src/hooks/useChatSession.test.tsx +++ b/src/hooks/useChatSession.test.tsx @@ -1,6 +1,8 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useChatSession } from './useChatSession' +import { messageStore } from '../store' +import { getSessionMessages, sendMessageAsync } from '../api' const { createSessionMock, @@ -501,3 +503,105 @@ describe('useChatSession busy UI signal', () => { expect(result.current.messageIsStreaming).toBe(true) }) }) + +describe('useChatSession sendMessageNow new-session key scoping', () => { + beforeEach(() => { + createSessionMock.mockReset() + summarizeSessionMock.mockReset() + executeCommandMock.mockReset() + getSelectableAgentsMock.mockReset() + registerSessionConsumerMock.mockReset() + updateConsumerSessionIdMock.mockReset() + sendNotificationMock.mockReset() + isSystemEnabledMock.mockReset() + errorHandlerMock.mockReset() + getPaneFullAutoModeMock.mockReset() + onFullAutoChangeMock.mockReset() + autoApproveSubscribeMock.mockReset() + shouldAutoApproveMock.mockReset() + claimAutoReplyMock.mockReset() + releaseAutoReplyMock.mockReset() + useSessionFamilyMock.mockReset() + handlePermissionReplyMock.mockReset() + refreshPendingRequestsMock.mockReset() + useSessionStateMock.mockReset() + pendingPermissionRequestsMock.length = 0 + for (const key of Object.keys(activeSessionStatusMap)) { + delete activeSessionStatusMap[key] + } + + registerSessionConsumerMock.mockReturnValue(vi.fn()) + getPaneFullAutoModeMock.mockReturnValue('off') + onFullAutoChangeMock.mockReturnValue(vi.fn()) + autoApproveSubscribeMock.mockReturnValue(vi.fn()) + shouldAutoApproveMock.mockReturnValue(false) + claimAutoReplyMock.mockReturnValue(true) + useSessionFamilyMock.mockReturnValue([]) + useSessionStateMock.mockReturnValue(null) + handlePermissionReplyMock.mockResolvedValue(true) + refreshPendingRequestsMock.mockResolvedValue(undefined) + autoApproveState.approvePendingOnFullAuto = false + getSelectableAgentsMock.mockResolvedValue([{ name: 'build', mode: 'primary', hidden: false }]) + isSystemEnabledMock.mockImplementation((type: string) => type !== 'permission') + + vi.mocked(sendMessageAsync).mockReset().mockResolvedValue(undefined) + vi.mocked(getSessionMessages).mockReset() + vi.mocked(messageStore.getSessionState).mockReset().mockReturnValue({ messages: [] }) + vi.mocked(messageStore.createSendRollbackSnapshot).mockReset().mockReturnValue(null) + vi.mocked(messageStore.setStreaming).mockReset() + vi.mocked(messageStore.handleMessageUpdated).mockReset() + vi.mocked(messageStore.handlePartUpdated).mockReset() + + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('scopes the new session with paneServerId for navigation, streaming flag and the SSE fallback pull', async () => { + createSessionMock.mockResolvedValue({ id: 'ses_new', directory: '/workspace/demo' }) + vi.mocked(getSessionMessages).mockResolvedValue([ + { + info: { id: 'msg_u', sessionID: 'ses_new', role: 'user', time: { created: 1, completed: 1 } }, + parts: [{ id: 'p1', sessionID: 'ses_new', messageID: 'msg_u', type: 'text', text: 'hi' }], + }, + ]) + const navigateToSessionProp = vi.fn() + + const { result } = renderHook(() => + useChatSession({ + paneId: 'pane-1', + chatAreaRef: { current: null }, + currentModel: { id: 'model-1', providerId: 'provider-1', variants: [] } as never, + refetchModels: vi.fn(async () => {}), + sessionId: null, + navigateToSession: navigateToSessionProp, + navigateHome: vi.fn(), + }), + ) + + let sendPromise!: Promise + await act(async () => { + sendPromise = result.current.handleSend('hello', []) + await vi.advanceTimersByTimeAsync(0) + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(1600) + await sendPromise + }) + + const scoped = 'local::ses_new' + expect(navigateToSessionProp).toHaveBeenCalledWith(scoped, '/workspace/demo') + expect(messageStore.setStreaming).toHaveBeenCalledWith(scoped, true) + expect(vi.mocked(sendMessageAsync)).toHaveBeenCalledWith(expect.objectContaining({ sessionId: scoped }), 'local') + expect(vi.mocked(getSessionMessages)).toHaveBeenCalledWith(scoped, 5, '/workspace/demo', 'local') + expect(messageStore.handleMessageUpdated).toHaveBeenCalledWith( + expect.objectContaining({ sessionID: scoped, id: 'msg_u' }), + ) + expect(messageStore.handlePartUpdated).toHaveBeenCalledWith( + expect.objectContaining({ sessionID: scoped, messageID: 'msg_u' }), + ) + }) +}) diff --git a/src/hooks/useChatSession.ts b/src/hooks/useChatSession.ts index d2c01ebe3..d0b1435a7 100644 --- a/src/hooks/useChatSession.ts +++ b/src/hooks/useChatSession.ts @@ -46,7 +46,7 @@ import { getMessageText, isUserMessage, type AssistantMessageInfo, type Message import { clipboardErrorHandler, copyTextToClipboard, createErrorHandler } from '../utils' import { clearSessionRuntimeState } from '../utils/sessionLifecycle' import { serverStorage } from '../utils/perServerStorage' -import { sessionKeyToServerId, splitSessionKey } from '../utils/sessionKey' +import { makeSessionKey, sessionKeyToServerId, splitSessionKey } from '../utils/sessionKey' import { serverStore } from '../store/serverStore' import { STORAGE_KEY_SELECTED_AGENT } from '../constants' import type { ChatAreaHandle } from '../features/chat' @@ -55,6 +55,9 @@ import { themeStore } from '../store/themeStore' const handleError = createErrorHandler('session') +/** 发送后等待 SSE 推送用户消息的兜底时延,超时未收到则主动拉取补齐 */ +const SEND_SSE_FALLBACK_DELAY_MS = 1500 + /** * Stable empty session state singleton. * @@ -683,7 +686,9 @@ export function useChatSession({ if (!sessionId) { if (!input.allowCreateSession) return false const newSession = await createSession() - sessionId = newSession.id + // store 与路由一律用复合键:SSE / loadSession / 兜底拉取都写复合键, + // 裸 id 会让 setStreaming 和 1500ms 兜底落到 UI 永远不读的键上 + sessionId = makeSessionKey(paneServerId, newSession.id) navigateToSession(sessionId, newSession.directory) } @@ -725,7 +730,7 @@ export function useChatSession({ getSessionMessages(pullSessionId, 5, pullDir, paneServerId) .then(apiMessages => { for (const msg of apiMessages) { - messageStore.handleMessageUpdated(msg.info) + messageStore.handleMessageUpdated({ ...msg.info, sessionID: pullSessionId }) if (msg.parts) { for (const part of msg.parts) { messageStore.handlePartUpdated({ @@ -740,7 +745,7 @@ export function useChatSession({ .catch(() => { // 拉取失败不影响主流程,SSE 重连后仍可补齐 }) - }, 1500) + }, SEND_SSE_FALLBACK_DELAY_MS) return true } catch (error) { diff --git a/src/hooks/useSessionManager.test.tsx b/src/hooks/useSessionManager.test.tsx index 1331931f9..e0373fe3b 100644 --- a/src/hooks/useSessionManager.test.tsx +++ b/src/hooks/useSessionManager.test.tsx @@ -80,4 +80,50 @@ describe('useSessionManager', () => { expect.objectContaining({ name: 'APIError' }), ) }) + + + it('preserves the SSE-delivered user message when the initial snapshot is stale-empty', async () => { + // 新建会话竞态:SSE 已把用户消息落到 loading 中的会话, + // 而初始快照读于发送提交之前(不含用户消息)——快照无权删掉它 + messageStoreMock.getSessionState.mockReturnValue({ + messages: [{ info: { id: 'msg-user', role: 'user', time: { created: 200, completed: 200 } }, parts: [] }], + loadState: 'loading', + isStale: false, + isStreaming: false, + }) + getSessionMessagesMock.mockResolvedValue([]) + + const { result } = renderHook(() => useSessionManager({ sessionId: null, directory: '/workspace/demo' })) + + await result.current.loadSession('session-1') + + expect(messageStoreMock.setMessages).toHaveBeenCalledWith( + 'session-1', + [expect.objectContaining({ info: expect.objectContaining({ id: 'msg-user' }) })], + expect.anything(), + ) + }) + + it('drops local-only messages older than the snapshot newest while loading', async () => { + // 截断窗口外的旧历史不走 SSE 补齐通道,加载期替换照常丢弃 + messageStoreMock.getSessionState.mockReturnValue({ + messages: [{ info: { id: 'msg-old', role: 'user', time: { created: 100, completed: 100 } }, parts: [] }], + loadState: 'loading', + isStale: false, + isStreaming: false, + }) + getSessionMessagesMock.mockResolvedValue([ + { info: { id: 'msg-new', role: 'user', time: { created: 200, completed: 200 } }, parts: [] }, + ]) + + const { result } = renderHook(() => useSessionManager({ sessionId: null, directory: '/workspace/demo' })) + + await result.current.loadSession('session-1') + + expect(messageStoreMock.setMessages).toHaveBeenCalledWith( + 'session-1', + [expect.objectContaining({ info: expect.objectContaining({ id: 'msg-new' }) })], + expect.anything(), + ) + }) }) diff --git a/src/hooks/useSessionManager.ts b/src/hooks/useSessionManager.ts index d0276b70f..fb9952dd2 100644 --- a/src/hooks/useSessionManager.ts +++ b/src/hooks/useSessionManager.ts @@ -94,8 +94,18 @@ function mergeWithLocalStreamingMessages( } }) - const localOnly = localState.isStreaming - ? localState.messages.filter(m => !apiIds.has(m.info.id)).map(toApiMessageWithParts) + // 初始加载在途(loading)或仍在流式时,本地独有且不比快照旧的消息, + // 必然是在快照查询之后经 SSE 到达的——快照无权删除它们; + // 比快照最新的还旧的本地消息属于窗口外历史(截断/会话被清空),照常丢弃 + const snapshotNewestCreated = apiMessages.reduce( + (max, m) => Math.max(max, m.info.time?.created ?? 0), + 0, + ) + const keepLocalOnly = localState.isStreaming || localState.loadState === 'loading' + const localOnly = keepLocalOnly + ? localState.messages + .filter(m => !apiIds.has(m.info.id) && (m.info.time?.created ?? 0) >= snapshotNewestCreated) + .map(toApiMessageWithParts) : [] if (localOnly.length === 0) return mergedApi