impl client message id and client retry

This commit is contained in:
2026-07-16 16:51:13 +08:00
parent 2a84e6d89b
commit 9dc65e1d47
11 changed files with 393 additions and 78 deletions
@@ -139,6 +139,7 @@ describe('useEditorAgentConversation', () => {
expect(client.sendMessage).toHaveBeenCalledWith(
'conversation-1',
expect.objectContaining({
clientMessageId: expect.stringMatching(/^editor-agent-/u),
text: '把这个角色改成像素风',
attachments: [],
}),
@@ -155,6 +156,9 @@ describe('useEditorAgentConversation', () => {
'把这个角色改成像素风',
'我来处理',
]);
expect(result.current.messages[0]?.clientMessageId).toMatch(
/^editor-agent-/u,
);
expect(result.current.messages[1]?.toolCall).toEqual(
expect.objectContaining({
toolName: 'generate_image',
@@ -83,13 +83,23 @@ function isAbortError(error: unknown) {
);
}
function createEditorAgentClientMessageId() {
const randomId =
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
return `editor-agent-${randomId}`;
}
function createLocalUserMessage(params: {
id: number;
clientMessageId: string;
text: string;
attachments: EditorAgentAttachmentRef[];
}): EditorAgentMessage {
return {
id: params.id,
clientMessageId: params.clientMessageId,
role: 'user',
text: params.text,
attachments: params.attachments,
@@ -359,12 +369,14 @@ export function useEditorAgentConversation({
return;
}
const conversationId = await ensureConversationForSend();
const clientMessageId = createEditorAgentClientMessageId();
const abortController = new AbortController();
activeRequestAbortControllerRef.current = abortController;
setErrorMessage(null);
setIsWaiting(true);
const optimisticMessage = createLocalUserMessage({
id: -1,
clientMessageId,
text,
attachments,
});
@@ -377,6 +389,7 @@ export function useEditorAgentConversation({
const response = await client.sendMessage(
conversationId,
{
clientMessageId,
text,
attachments,
},
+54
View File
@@ -599,6 +599,60 @@ describe('apiClient', () => {
expect(result).toEqual({ value: 42 });
});
it('reuses the exact post body and request id for an enabled unsafe transport retry', async () => {
setStoredAccessToken('editor-agent-token', { emit: false });
fetchMock
.mockRejectedValueOnce(new TypeError('network unavailable'))
.mockResolvedValueOnce(
createResponseMock({
status: 200,
body: JSON.stringify({
ok: true,
data: { deltaMessages: [] },
error: null,
meta: { apiVersion: '2026-06-16' },
}),
headers: { 'Content-Type': 'application/json' },
}),
);
const body = JSON.stringify({
clientMessageId: 'client-message-1',
text: '生成一张图',
attachments: [],
});
await requestJson(
'/api/editor/agent-conversations/conversation-1/messages',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
},
'发送画布 Agent 消息失败',
{
authImpact: 'local',
retry: {
maxRetries: 1,
baseDelayMs: 1,
maxDelayMs: 1,
retryUnsafeMethods: true,
},
},
);
expect(fetchMock).toHaveBeenCalledTimes(2);
const firstInit = fetchMock.mock.calls[0]?.[1] as RequestInit;
const secondInit = fetchMock.mock.calls[1]?.[1] as RequestInit;
expect(firstInit.body).toBe(body);
expect(secondInit.body).toBe(body);
expect((firstInit.headers as Record<string, string>)['x-request-id']).toBe(
'web-11111111-2222-3333-4444-555555555555',
);
expect((secondInit.headers as Record<string, string>)['x-request-id']).toBe(
(firstInit.headers as Record<string, string>)['x-request-id'],
);
});
it('aborts requests when timeoutMs is reached', async () => {
setStoredAccessToken('timeout-token', { emit: false });
fetchMock.mockImplementation(
@@ -123,6 +123,7 @@ describe('editorAgentClient', () => {
requestJsonMock.mockResolvedValueOnce(responseBody);
const result = await sendEditorAgentMessage('conversation-1', {
clientMessageId: 'client-message-1',
text: '帮我把角色改成像素风',
attachments: [],
});
@@ -134,6 +135,7 @@ describe('editorAgentClient', () => {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
clientMessageId: 'client-message-1',
text: '帮我把角色改成像素风',
attachments: [],
}),
@@ -142,6 +144,12 @@ describe('editorAgentClient', () => {
expect.objectContaining({
timeoutMs: 1_200_000,
authImpact: 'local',
retry: {
maxRetries: 1,
baseDelayMs: 250,
maxDelayMs: 250,
retryUnsafeMethods: true,
},
}),
);
});
@@ -12,6 +12,12 @@ import { requestJson } from '../apiClient';
const EDITOR_PROJECT_AGENT_CONVERSATION_API_BASE = '/api/editor/projects';
const EDITOR_AGENT_CONVERSATION_API_BASE = '/api/editor/agent-conversations';
const EDITOR_AGENT_MESSAGE_TIMEOUT_MS = 1_200_000;
const EDITOR_AGENT_MESSAGE_RETRY = {
maxRetries: 1,
baseDelayMs: 250,
maxDelayMs: 250,
retryUnsafeMethods: true,
} as const;
export type SendEditorAgentMessageOptions = {
signal?: AbortSignal;
@@ -116,6 +122,7 @@ export async function sendEditorAgentMessage(
{
timeoutMs: EDITOR_AGENT_MESSAGE_TIMEOUT_MS,
authImpact: 'local',
retry: EDITOR_AGENT_MESSAGE_RETRY,
},
);
}