fix: 拆分单调请求序号与当前生效请求 ID,避免被压制的 refresh 与后续切换 ID 碰撞。
This commit is contained in:
@@ -64,7 +64,7 @@
|
||||
- 会话管理入口在对话框头部:当前会话标题 + 历史会话下拉(按更新时间倒序)+ 新建对话按钮,全部包在对话框内。
|
||||
- 当前会话没有任何已发送消息时,新建对话按钮置灰且不可点击;输入框草稿和未发送附件不算会话内容。当前会话已有消息时可新建,新建成功后只切换到返回的空白会话,输入文字、附件及附件选择状态与切换历史会话时一样原样保留,旧会话继续保留在历史会话下拉中;创建失败同样不修改草稿。
|
||||
- 新会话创建请求 pending 时禁用历史会话下拉和发送动作,但输入框与附件仍可编辑;会话列表或历史消息加载期间同样禁用发送。表单提交处理器必须复用相同门禁,不能先清空草稿再由 hook 静默跳过发送。
|
||||
- 快速切换会话或会话轮询刷新产生并发详情请求时,前端只允许最后发起的请求更新当前会话、消息、错误和加载态;旧响应不得覆盖用户最新选择。
|
||||
- 快速切换会话或会话轮询刷新产生并发详情请求时,每个请求必须获得唯一且单调递增的请求序号;前端只允许最后发起且有权生效的请求更新当前会话、消息、错误和加载态。被正在进行的会话切换压制的旧会话 refresh 不得提前结束新切换的加载态,旧响应也不得覆盖用户最新选择。
|
||||
- 普通 JSON 消息请求的回包必须绑定发送时的会话:用户在等待期间切换到其他会话后,只更新原会话的列表摘要,不得把原会话的 `deltaMessages` 、错误或画布刷新副作用应用到当前面板。
|
||||
- 收起对话框只是隐藏面板,不卸载当前会话 hook;普通 JSON 消息请求的等待态和外部生成任务状态必须在收起 / 重新打开之间保持一致。
|
||||
|
||||
|
||||
+106
@@ -351,6 +351,112 @@ describe('useEditorAgentConversation', () => {
|
||||
expect(result.current.isLoadingMessages).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the latest activation loading while an older suppressed refresh finishes', async () => {
|
||||
const client = createClient();
|
||||
let resolveConversation2!: (detail: EditorAgentConversationDetail) => void;
|
||||
let resolveRefresh!: (detail: EditorAgentConversationDetail) => void;
|
||||
let resolveConversation3!: (detail: EditorAgentConversationDetail) => void;
|
||||
const conversation2Promise = new Promise<EditorAgentConversationDetail>(
|
||||
(resolve) => {
|
||||
resolveConversation2 = resolve;
|
||||
},
|
||||
);
|
||||
const refreshPromise = new Promise<EditorAgentConversationDetail>(
|
||||
(resolve) => {
|
||||
resolveRefresh = resolve;
|
||||
},
|
||||
);
|
||||
const conversation3Promise = new Promise<EditorAgentConversationDetail>(
|
||||
(resolve) => {
|
||||
resolveConversation3 = resolve;
|
||||
},
|
||||
);
|
||||
const { result } = renderHook(() =>
|
||||
useEditorAgentConversation({ projectId: 'project-1', client }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeConversationId).toBe('conversation-1');
|
||||
});
|
||||
vi.mocked(client.getConversation).mockImplementation((conversationId) => {
|
||||
if (conversationId === 'conversation-2') {
|
||||
return conversation2Promise;
|
||||
}
|
||||
if (conversationId === 'conversation-1') {
|
||||
return refreshPromise;
|
||||
}
|
||||
if (conversationId === 'conversation-3') {
|
||||
return conversation3Promise;
|
||||
}
|
||||
throw new Error(`Unexpected conversation: ${conversationId}`);
|
||||
});
|
||||
|
||||
let conversation2Load!: Promise<EditorAgentConversationDetail | null>;
|
||||
let pendingRefresh!: Promise<EditorAgentConversationDetail | null>;
|
||||
let conversation3Load!: Promise<EditorAgentConversationDetail | null>;
|
||||
act(() => {
|
||||
conversation2Load = result.current.selectConversation('conversation-2');
|
||||
pendingRefresh = result.current.refreshActiveConversation();
|
||||
conversation3Load = result.current.selectConversation('conversation-3');
|
||||
});
|
||||
expect(result.current.isLoadingMessages).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
resolveRefresh({
|
||||
conversationId: 'conversation-1',
|
||||
projectId: 'project-1',
|
||||
title: '旧会话刷新结果',
|
||||
messages: [],
|
||||
createdAt: '2026-07-03T00:00:00.000Z',
|
||||
updatedAt: '2026-07-03T00:02:00.000Z',
|
||||
});
|
||||
await pendingRefresh;
|
||||
});
|
||||
|
||||
expect(result.current.activeConversationId).toBe('conversation-1');
|
||||
expect(result.current.isLoadingMessages).toBe(true);
|
||||
await act(async () => {
|
||||
await result.current.sendMessage('切换期间不应发送');
|
||||
});
|
||||
expect(client.sendMessage).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
resolveConversation3({
|
||||
conversationId: 'conversation-3',
|
||||
projectId: 'project-1',
|
||||
title: '第三个会话',
|
||||
messages: [
|
||||
{
|
||||
id: 30,
|
||||
role: 'assistant',
|
||||
text: '第三个会话消息',
|
||||
attachments: [],
|
||||
toolCall: null,
|
||||
createdAt: '2026-07-03T00:03:00.000Z',
|
||||
},
|
||||
],
|
||||
createdAt: '2026-07-03T00:03:00.000Z',
|
||||
updatedAt: '2026-07-03T00:03:00.000Z',
|
||||
});
|
||||
await conversation3Load;
|
||||
});
|
||||
expect(result.current.activeConversationId).toBe('conversation-3');
|
||||
expect(result.current.isLoadingMessages).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
resolveConversation2({
|
||||
conversationId: 'conversation-2',
|
||||
projectId: 'project-1',
|
||||
title: '第二个会话',
|
||||
messages: [],
|
||||
createdAt: '2026-07-03T00:02:00.000Z',
|
||||
updatedAt: '2026-07-03T00:02:00.000Z',
|
||||
});
|
||||
await conversation2Load;
|
||||
});
|
||||
expect(result.current.activeConversationId).toBe('conversation-3');
|
||||
});
|
||||
|
||||
it('does not let a silent refresh replace a newly created conversation', async () => {
|
||||
const client = createClient();
|
||||
let resolveRefresh!: (detail: EditorAgentConversationDetail) => void;
|
||||
|
||||
@@ -160,6 +160,7 @@ export function useEditorAgentConversation({
|
||||
const activeConversationIdRef = useRef<string | null>(null);
|
||||
const pendingActivationConversationIdRef = useRef<string | null>(null);
|
||||
const activeToolCallActionRef = useRef<EditorAgentToolCallActionState>(null);
|
||||
const conversationLoadRequestSequenceRef = useRef(0);
|
||||
const conversationLoadRequestIdRef = useRef(0);
|
||||
const createConversationRequestIdRef = useRef(0);
|
||||
const isWaitingRef = useRef(false);
|
||||
@@ -234,7 +235,8 @@ export function useEditorAgentConversation({
|
||||
showLoading?: boolean;
|
||||
},
|
||||
) => {
|
||||
const requestId = conversationLoadRequestIdRef.current + 1;
|
||||
const requestId = conversationLoadRequestSequenceRef.current + 1;
|
||||
conversationLoadRequestSequenceRef.current = requestId;
|
||||
const maySupersedeCurrentLoad =
|
||||
options.mode === 'activate' ||
|
||||
pendingActivationConversationIdRef.current === null;
|
||||
@@ -284,7 +286,10 @@ export function useEditorAgentConversation({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
conversationLoadRequestIdRef.current += 1;
|
||||
const invalidationRequestId =
|
||||
conversationLoadRequestSequenceRef.current + 1;
|
||||
conversationLoadRequestSequenceRef.current = invalidationRequestId;
|
||||
conversationLoadRequestIdRef.current = invalidationRequestId;
|
||||
pendingActivationConversationIdRef.current = null;
|
||||
setIsLoadingMessages(false);
|
||||
if (!normalizedProjectId) {
|
||||
@@ -356,7 +361,10 @@ export function useEditorAgentConversation({
|
||||
createConversationRequestIdRef.current === requestId &&
|
||||
normalizedProjectIdRef.current === requestedProjectId
|
||||
) {
|
||||
conversationLoadRequestIdRef.current += 1;
|
||||
const invalidationRequestId =
|
||||
conversationLoadRequestSequenceRef.current + 1;
|
||||
conversationLoadRequestSequenceRef.current = invalidationRequestId;
|
||||
conversationLoadRequestIdRef.current = invalidationRequestId;
|
||||
pendingActivationConversationIdRef.current = null;
|
||||
setIsLoadingMessages(false);
|
||||
applyConversationDetail(detail);
|
||||
|
||||
Reference in New Issue
Block a user