Skip to content

Commit a8a6300

Browse files
arvinxxclaude
andauthored
🐛 fix(store): delete message before regeneration (lobehub#11760)
🐛 fix(store): delete message before regeneration to fix LOBE-2533 When "delete and regenerate" was called, regeneration happened first which switched to a new branch. This caused the original message to no longer appear in displayMessages, so deleteMessage couldn't find the message and failed silently. Changes: - Reorder operations: delete first, then regenerate - Get parent user message ID before deletion (needed for regeneration) - Add early return if message has no parentId - Add test case to verify correct operation order Closes: LOBE-2533 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 61cb4ee commit a8a6300

File tree

2 files changed

+130
-3
lines changed

2 files changed

+130
-3
lines changed

src/features/Conversation/store/slices/generation/action.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,122 @@ describe('Generation Actions', () => {
462462
// Should complete operation
463463
expect(mockCompleteOperation).toHaveBeenCalledWith('test-op-id');
464464
});
465+
466+
it('should delete message BEFORE regeneration to prevent message not found issue (LOBE-2533)', async () => {
467+
// This test verifies the fix for LOBE-2533:
468+
// When "delete and regenerate" is called, if regeneration happens first,
469+
// it switches to a new branch, causing the original message to no longer
470+
// appear in displayMessages. Then deleteMessage cannot find the message
471+
// and fails silently.
472+
//
473+
// The fix: delete first, then regenerate.
474+
475+
const callOrder: string[] = [];
476+
477+
// Re-setup mock to track call order
478+
const { useChatStore } = await import('@/store/chat');
479+
vi.mocked(useChatStore.getState).mockReturnValue({
480+
messagesMap: {},
481+
operations: {},
482+
messageLoadingIds: [],
483+
cancelOperations: mockCancelOperations,
484+
cancelOperation: mockCancelOperation,
485+
deleteMessage: vi.fn().mockImplementation(() => {
486+
callOrder.push('deleteMessage');
487+
return Promise.resolve();
488+
}),
489+
switchMessageBranch: vi.fn().mockImplementation(() => {
490+
callOrder.push('switchMessageBranch');
491+
return Promise.resolve();
492+
}),
493+
startOperation: mockStartOperation,
494+
completeOperation: mockCompleteOperation,
495+
failOperation: mockFailOperation,
496+
internal_execAgentRuntime: vi.fn().mockImplementation(() => {
497+
callOrder.push('internal_execAgentRuntime');
498+
return Promise.resolve();
499+
}),
500+
} as any);
501+
502+
const context: ConversationContext = {
503+
agentId: 'session-1',
504+
topicId: 'topic-1',
505+
threadId: null,
506+
groupId: 'group-1',
507+
};
508+
509+
const store = createStore({ context });
510+
511+
// Set displayMessages and dbMessages
512+
act(() => {
513+
store.setState({
514+
displayMessages: [
515+
{ id: 'msg-1', role: 'user', content: 'Hello' },
516+
{ id: 'msg-2', role: 'assistant', content: 'Hi there', parentId: 'msg-1' },
517+
],
518+
dbMessages: [
519+
{ id: 'msg-1', role: 'user', content: 'Hello' },
520+
{ id: 'msg-2', role: 'assistant', content: 'Hi there', parentId: 'msg-1' },
521+
],
522+
} as any);
523+
});
524+
525+
await act(async () => {
526+
await store.getState().delAndRegenerateMessage('msg-2');
527+
});
528+
529+
// CRITICAL: deleteMessage must be called BEFORE switchMessageBranch and internal_execAgentRuntime
530+
// If regeneration (which calls switchMessageBranch) happens first, the message
531+
// won't be found in displayMessages and deletion will fail silently.
532+
expect(callOrder[0]).toBe('deleteMessage');
533+
expect(callOrder).toContain('switchMessageBranch');
534+
expect(callOrder).toContain('internal_execAgentRuntime');
535+
536+
// Verify deleteMessage is called before any regeneration-related calls
537+
const deleteIndex = callOrder.indexOf('deleteMessage');
538+
const switchIndex = callOrder.indexOf('switchMessageBranch');
539+
const execIndex = callOrder.indexOf('internal_execAgentRuntime');
540+
541+
expect(deleteIndex).toBeLessThan(switchIndex);
542+
expect(deleteIndex).toBeLessThan(execIndex);
543+
});
544+
545+
it('should not proceed if assistant message has no parentId', async () => {
546+
const { useChatStore } = await import('@/store/chat');
547+
vi.mocked(useChatStore.getState).mockReturnValue({
548+
messagesMap: {},
549+
operations: {},
550+
messageLoadingIds: [],
551+
startOperation: mockStartOperation,
552+
completeOperation: mockCompleteOperation,
553+
deleteMessage: mockDeleteMessage,
554+
} as any);
555+
556+
const context: ConversationContext = {
557+
agentId: 'session-1',
558+
topicId: null,
559+
threadId: null,
560+
};
561+
562+
const store = createStore({ context });
563+
564+
// Set displayMessages with assistant message that has no parentId
565+
act(() => {
566+
store.setState({
567+
displayMessages: [
568+
{ id: 'msg-1', role: 'assistant', content: 'Hi there' }, // no parentId
569+
],
570+
} as any);
571+
});
572+
573+
await act(async () => {
574+
await store.getState().delAndRegenerateMessage('msg-1');
575+
});
576+
577+
// Should not proceed - no operation created, no delete called
578+
expect(mockStartOperation).not.toHaveBeenCalled();
579+
expect(mockDeleteMessage).not.toHaveBeenCalled();
580+
});
465581
});
466582

467583
describe('delAndResendThreadMessage', () => {

src/features/Conversation/store/slices/generation/action.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -206,18 +206,29 @@ export const generationSlice: StateCreator<
206206
},
207207

208208
delAndRegenerateMessage: async (messageId: string) => {
209-
const { context } = get();
209+
const { context, displayMessages } = get();
210210
const chatStore = useChatStore.getState();
211211

212+
// Find the assistant message and get parent user message ID before deletion
213+
// This is needed because after deletion, we can't find the parent anymore
214+
const currentMessage = displayMessages.find((c) => c.id === messageId);
215+
if (!currentMessage) return;
216+
217+
const userId = currentMessage.parentId;
218+
if (!userId) return;
219+
212220
// Create operation to track context (use 'regenerate' type since this is a regenerate action)
213221
const { operationId } = chatStore.startOperation({
214222
context: { ...context, messageId },
215223
type: 'regenerate',
216224
});
217225

218-
// Regenerate first, then delete
219-
await get().regenerateAssistantMessage(messageId);
226+
// IMPORTANT: Delete first, then regenerate (LOBE-2533)
227+
// If we regenerate first, it switches to a new branch, causing the original
228+
// message to no longer appear in displayMessages. Then deleteMessage cannot
229+
// find the message and fails silently.
220230
await chatStore.deleteMessage(messageId, { operationId });
231+
await get().regenerateUserMessage(userId);
221232
chatStore.completeOperation(operationId);
222233
},
223234

0 commit comments

Comments
 (0)