fix message id mismatch in frontend

This commit is contained in:
2026-07-15 19:18:34 +08:00
parent c6bfdafc43
commit 9856acb8c3
7 changed files with 39 additions and 24 deletions
+3 -2
View File
@@ -96,8 +96,9 @@ export interface EditorAgentToolCall {
}
export interface EditorAgentMessage {
// frontend should not depend on this, it is used only for locate tool call
// id: number;
// Frontend must not use this to organize messages.
// It is an opaque backend locator for pending tool-call operations.
id: number;
role: EditorAgentMessageRole;
text: string;
attachments: EditorAgentAttachmentRef[];
@@ -71,6 +71,7 @@ function createClient(): EditorAgentConversationClient {
title: '角色参考',
messages: [
{
id: 0,
role: 'assistant',
text: '已经看到画布内容',
attachments: [],
@@ -98,6 +99,7 @@ function createClient(): EditorAgentConversationClient {
},
deltaMessages: [
{
id: 1,
role: 'assistant',
text: '收到,我会参考这张图。',
attachments: [],
@@ -114,6 +116,7 @@ function createClient(): EditorAgentConversationClient {
function createPendingToolCallMessage(): EditorAgentMessage {
return {
id: 2,
role: 'system',
text: 'internal system prompt that must stay hidden',
attachments: [],
@@ -454,6 +457,7 @@ describe('EditorAgentConversationPanelView', () => {
},
deltaMessages: [
{
id: 1,
role: 'assistant',
text: '我来生成图片。',
attachments: [],
@@ -550,7 +554,7 @@ describe('EditorAgentConversationPanelView', () => {
fireEvent.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() => {
expect(client.confirmToolCall).toHaveBeenCalledWith('conversation-1', 0);
expect(client.confirmToolCall).toHaveBeenCalledWith('conversation-1', 2);
});
expect(screen.getByRole('button', { name: '执行中' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '确认' })).toBeNull();
@@ -561,6 +565,7 @@ describe('EditorAgentConversationPanelView', () => {
await act(async () => {
resolveConfirmation({
...pendingMessage,
id: 2,
text: 'internal completed tool output that must stay hidden',
toolCall: {
...pendingMessage.toolCall!,
@@ -620,7 +625,7 @@ describe('EditorAgentConversationPanelView', () => {
fireEvent.click(await screen.findByRole('button', { name: '取消' }));
expect(await screen.findByText('已取消')).toBeTruthy();
expect(client.cancelToolCall).toHaveBeenCalledWith('conversation-1', 0);
expect(client.cancelToolCall).toHaveBeenCalledWith('conversation-1', 2);
expect(
screen.queryByText(
'internal cancelled tool output that must stay hidden',
@@ -598,9 +598,8 @@ export function EditorAgentConversationPanelView({
<MessageBubble
key={`${message.createdAt}-${messageIndex}`}
message={message}
messageIndex={messageIndex}
busyAction={
toolCallAction?.messageIndex === messageIndex
toolCallAction?.messageId === message.id
? toolCallAction.action
: null
}
@@ -38,7 +38,6 @@ export function ThinkingBubble() {
type MessageBubbleProps = {
message: EditorAgentMessage;
messageIndex: number;
busyAction: 'confirm' | 'cancel' | null;
onConfirmToolCall: (messageId: number) => void;
onCancelToolCall: (messageId: number) => void;
@@ -47,7 +46,6 @@ type MessageBubbleProps = {
export function MessageBubble({
message,
messageIndex,
busyAction,
onConfirmToolCall,
onCancelToolCall,
@@ -64,7 +62,7 @@ export function MessageBubble({
) {
return (
<PendingToolCall
messageId={messageIndex}
messageId={message.id}
toolCall={message.toolCall}
busyAction={busyAction}
onConfirm={onConfirmToolCall}
@@ -75,6 +75,7 @@ function createClient(): EditorAgentConversationClient {
},
deltaMessages: [
{
id: 1,
role: 'assistant',
text: '我来处理',
attachments: [],
@@ -171,6 +172,7 @@ describe('useEditorAgentConversation', () => {
it('appends a lazily reconciled tool message delta', async () => {
const client = createClient();
const pendingMessage: EditorAgentMessage = {
id: 0,
role: 'system',
text: 'pending edit',
attachments: [],
@@ -335,6 +337,7 @@ describe('useEditorAgentConversation', () => {
},
deltaMessages: [
{
id: 2,
role: 'system',
text: '需要生成一张图',
attachments: [],
@@ -355,6 +358,7 @@ describe('useEditorAgentConversation', () => {
errorMessage: null,
} as EditorAgentMessageResponse);
vi.mocked(client.confirmToolCall).mockResolvedValue({
id: 2,
role: 'system',
text: 'internal completed tool output',
attachments: [],
@@ -404,14 +408,15 @@ describe('useEditorAgentConversation', () => {
const systemMsg = result.current.messages[1];
expect(systemMsg?.role).toBe('system');
expect(systemMsg?.id).toBe(2);
expect(systemMsg?.toolCall?.externalJobId).toBeUndefined();
const messageCount = result.current.messages.length;
await act(async () => {
await result.current.confirmToolCall(1);
await result.current.confirmToolCall(systemMsg!.id);
});
expect(client.confirmToolCall).toHaveBeenCalledWith('conversation-1', 1);
expect(client.confirmToolCall).toHaveBeenCalledWith('conversation-1', 2);
expect(result.current.messages).toHaveLength(messageCount);
expect(result.current.messages[1]?.toolCall?.externalJobId).toBe(
'task-edit-1',
@@ -426,6 +431,7 @@ describe('useEditorAgentConversation', () => {
it('keeps the action busy until confirmation returns an external job id', async () => {
const client = createClient();
const pendingMessage: EditorAgentMessage = {
id: 7,
role: 'system',
text: '需要生成一张图',
attachments: [],
@@ -476,10 +482,10 @@ describe('useEditorAgentConversation', () => {
let confirmationPromise: Promise<EditorAgentMessage | null> | undefined;
act(() => {
confirmationPromise = result.current.confirmToolCall(0);
confirmationPromise = result.current.confirmToolCall(7);
});
await waitFor(() => expect(resolveConfirmation).toBeTypeOf('function'));
expect(client.confirmToolCall).toHaveBeenCalledTimes(1);
expect(client.confirmToolCall).toHaveBeenCalledWith('conversation-1', 7);
await act(async () => {
resolveConfirmation?.(completedMessage);
@@ -495,6 +501,7 @@ describe('useEditorAgentConversation', () => {
it('cancels a pending tool call and keeps the replacement in the same position', async () => {
const client = createClient();
const pendingMessage: EditorAgentMessage = {
id: 9,
role: 'system' as const,
text: 'internal pending tool prompt',
attachments: [],
@@ -537,10 +544,10 @@ describe('useEditorAgentConversation', () => {
expect(result.current.messages[0]?.toolCall?.status).toBe('not_completed');
});
await act(async () => {
await result.current.cancelToolCall(0);
await result.current.cancelToolCall(9);
});
expect(client.cancelToolCall).toHaveBeenCalledWith('conversation-1', 0);
expect(client.cancelToolCall).toHaveBeenCalledWith('conversation-1', 9);
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0]?.toolCall?.status).toBe('cancelled');
expect(onCanvasRefreshRequested).not.toHaveBeenCalled();
@@ -59,7 +59,7 @@ type UseEditorAgentConversationOptions = {
export type EditorAgentToolCallAction = 'confirm' | 'cancel';
export type EditorAgentToolCallActionState = {
messageIndex: number;
messageId: number;
action: EditorAgentToolCallAction;
} | null;
@@ -84,10 +84,12 @@ function isAbortError(error: unknown) {
}
function createLocalUserMessage(params: {
id: number;
text: string;
attachments: EditorAgentAttachmentRef[];
}): EditorAgentMessage {
return {
id: params.id,
role: 'user',
text: params.text,
attachments: params.attachments,
@@ -356,6 +358,7 @@ export function useEditorAgentConversation({
setMessages((currentMessages) => [
...currentMessages,
createLocalUserMessage({
id: -1, // does not care
text,
attachments,
}),
@@ -415,29 +418,29 @@ export function useEditorAgentConversation({
}, []);
const resolveToolCall = useCallback(
async (messageIndex: number, action: EditorAgentToolCallAction) => {
async (messageId: number, action: EditorAgentToolCallAction) => {
const conversationId = activeConversationIdRef.current;
if (!conversationId || activeToolCallActionRef.current) {
return null;
}
const nextAction = { messageIndex, action } as const;
const nextAction = { messageId, action } as const;
activeToolCallActionRef.current = nextAction;
setToolCallAction(nextAction);
setErrorMessage(null);
try {
const updatedMessage = await (action === 'confirm'
? client.confirmToolCall(conversationId, messageIndex)
: client.cancelToolCall(conversationId, messageIndex));
? client.confirmToolCall(conversationId, messageId)
: client.cancelToolCall(conversationId, messageId));
if (activeConversationIdRef.current !== conversationId) {
return updatedMessage;
}
setMessages((currentMessages) =>
currentMessages.map((message, index) =>
index === messageIndex ? updatedMessage : message,
currentMessages.map((message) =>
message.id === messageId ? updatedMessage : message,
),
);
if (action === 'confirm') {
@@ -220,6 +220,7 @@ function createEditorAgentDetailWithGeneration(
createdAt: '2026-07-03T00:00:00.000Z',
messages: [
{
id: 0,
role: 'system',
text: 'internal completed tool output',
attachments: [],
@@ -2143,6 +2144,7 @@ describe('ImageCanvasEditorView', () => {
createdAt: '2026-07-03T00:00:00.000Z',
messages: [
{
id: 3,
role: 'system',
text: 'internal pending tool prompt',
attachments: [],
@@ -2165,7 +2167,7 @@ describe('ImageCanvasEditorView', () => {
confirmEditorAgentToolCallMock.mockImplementation(async () => {
showGeneratedAsset = true;
return {
id: 0,
id: 3,
role: 'system',
text: 'internal completed tool output',
attachments: [],
@@ -2242,7 +2244,7 @@ describe('ImageCanvasEditorView', () => {
await waitFor(() => {
expect(confirmEditorAgentToolCallMock).toHaveBeenCalledWith(
'editor-agent-conv-test',
0,
3,
);
expect(loadEditorProjectMock).toHaveBeenCalledWith(
'editor-project-default',