Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions src/hooks/useChatSession.test.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -501,3 +503,105 @@
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: [] })

Check failure on line 549 in src/hooks/useChatSession.test.tsx

View workflow job for this annotation

GitHub Actions / validate

Argument of type '{ messages: never[]; }' is not assignable to parameter of type 'SessionState'.
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 } },

Check failure on line 567 in src/hooks/useChatSession.test.tsx

View workflow job for this annotation

GitHub Actions / validate

Type '{ id: string; sessionID: string; role: "user"; time: { created: number; completed: number; }; }' is not assignable to type 'Message'.
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<boolean>
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' }),
)
})
})
13 changes: 9 additions & 4 deletions src/hooks/useChatSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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.
*
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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({
Expand All @@ -740,7 +745,7 @@ export function useChatSession({
.catch(() => {
// 拉取失败不影响主流程,SSE 重连后仍可补齐
})
}, 1500)
}, SEND_SSE_FALLBACK_DELAY_MS)

return true
} catch (error) {
Expand Down
46 changes: 46 additions & 0 deletions src/hooks/useSessionManager.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
)
})
})
14 changes: 12 additions & 2 deletions src/hooks/useSessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading