fix: refresh can cancel an in-flight activation
Project CI / Repository checks (pull_request) Successful in 1m29s
Project CI / Frontend tests (pull_request) Successful in 2m31s
Project CI / Native shell tests (pull_request) Successful in 2m57s
Project CI / Backend tests (pull_request) Successful in 3m32s

This commit is contained in:
2026-07-24 17:48:01 +08:00
parent 94cd0e000a
commit 6d604999be
2 changed files with 109 additions and 3 deletions
@@ -264,6 +264,93 @@ describe('useEditorAgentConversation', () => {
expect(result.current.isLoadingMessages).toBe(false);
});
it('does not let a refresh supersede an in-flight conversation activation', async () => {
const client = createClient();
let resolveActivation!: (detail: EditorAgentConversationDetail) => void;
let resolveRefresh!: (detail: EditorAgentConversationDetail) => void;
const activationPromise = new Promise<EditorAgentConversationDetail>(
(resolve) => {
resolveActivation = resolve;
},
);
const refreshPromise = new Promise<EditorAgentConversationDetail>(
(resolve) => {
resolveRefresh = 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 activationPromise;
}
if (conversationId === 'conversation-1') {
return refreshPromise;
}
throw new Error(`Unexpected conversation: ${conversationId}`);
});
let pendingActivation!: Promise<EditorAgentConversationDetail | null>;
let pendingRefresh!: Promise<EditorAgentConversationDetail | null>;
act(() => {
pendingActivation = result.current.selectConversation('conversation-2');
pendingRefresh = result.current.refreshActiveConversation();
});
await act(async () => {
resolveRefresh({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '旧会话刷新结果',
messages: [
{
id: 10,
role: 'assistant',
text: '旧会话刷新消息',
attachments: [],
toolCall: null,
createdAt: '2026-07-03T00:02:00.000Z',
},
],
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.messages).toEqual([]);
await act(async () => {
resolveActivation({
conversationId: 'conversation-2',
projectId: 'project-1',
title: '第二个会话',
messages: [
{
id: 20,
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 pendingActivation;
});
expect(result.current.activeConversationId).toBe('conversation-2');
expect(result.current.messages[0]?.text).toBe('第二个会话消息');
expect(result.current.isLoadingMessages).toBe(false);
});
it('does not let a silent refresh replace a newly created conversation', async () => {
const client = createClient();
let resolveRefresh!: (detail: EditorAgentConversationDetail) => void;
@@ -158,6 +158,7 @@ export function useEditorAgentConversation({
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const normalizedProjectIdRef = useRef(normalizedProjectId);
const activeConversationIdRef = useRef<string | null>(null);
const pendingActivationConversationIdRef = useRef<string | null>(null);
const activeToolCallActionRef = useRef<EditorAgentToolCallActionState>(null);
const conversationLoadRequestIdRef = useRef(0);
const createConversationRequestIdRef = useRef(0);
@@ -234,13 +235,22 @@ export function useEditorAgentConversation({
},
) => {
const requestId = conversationLoadRequestIdRef.current + 1;
conversationLoadRequestIdRef.current = requestId;
const maySupersedeCurrentLoad =
options.mode === 'activate' ||
pendingActivationConversationIdRef.current === null;
if (maySupersedeCurrentLoad) {
conversationLoadRequestIdRef.current = requestId;
}
if (options.mode === 'activate') {
pendingActivationConversationIdRef.current = conversationId;
}
const showLoading = options.showLoading ?? true;
const canApplyResult = () =>
conversationLoadRequestIdRef.current === requestId &&
(options.mode === 'activate' ||
activeConversationIdRef.current === conversationId);
if (showLoading) {
(pendingActivationConversationIdRef.current === null &&
activeConversationIdRef.current === conversationId));
if (showLoading && maySupersedeCurrentLoad) {
setIsLoadingMessages(true);
}
setErrorMessage(null);
@@ -258,6 +268,13 @@ export function useEditorAgentConversation({
}
throw error;
} finally {
if (
options.mode === 'activate' &&
conversationLoadRequestIdRef.current === requestId &&
pendingActivationConversationIdRef.current === conversationId
) {
pendingActivationConversationIdRef.current = null;
}
if (conversationLoadRequestIdRef.current === requestId) {
setIsLoadingMessages(false);
}
@@ -268,6 +285,7 @@ export function useEditorAgentConversation({
useEffect(() => {
conversationLoadRequestIdRef.current += 1;
pendingActivationConversationIdRef.current = null;
setIsLoadingMessages(false);
if (!normalizedProjectId) {
setConversations([]);
@@ -339,6 +357,7 @@ export function useEditorAgentConversation({
normalizedProjectIdRef.current === requestedProjectId
) {
conversationLoadRequestIdRef.current += 1;
pendingActivationConversationIdRef.current = null;
setIsLoadingMessages(false);
applyConversationDetail(detail);
}