remove SSE-based implementation for editor agent

This commit is contained in:
2026-07-10 11:23:26 +08:00
parent 084214e2ed
commit 365ada84e3
5 changed files with 157 additions and 344 deletions
@@ -1,41 +1,24 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
cancelEditorAgentToolCall,
confirmEditorAgentToolCall,
createEditorAgentConversation,
deleteEditorAgentConversation,
getEditorAgentConversation,
listEditorAgentConversations,
streamEditorAgentMessage,
sendEditorAgentMessage,
} from './editorAgentClient';
const requestJsonMock = vi.hoisted(() => vi.fn());
const fetchWithApiAuthMock = vi.hoisted(() => vi.fn());
vi.mock('../apiClient', () => ({
requestJson: requestJsonMock,
fetchWithApiAuth: fetchWithApiAuthMock,
}));
function createSseResponse(payload: string) {
const encoder = new TextEncoder();
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(payload));
controller.close();
},
}),
{
status: 200,
headers: { 'Content-Type': 'text/event-stream; charset=utf-8' },
},
);
}
describe('editorAgentClient', () => {
afterEach(() => {
requestJsonMock.mockReset();
fetchWithApiAuthMock.mockReset();
});
it('uses the planned editor agent conversation CRUD routes', async () => {
@@ -117,53 +100,96 @@ describe('editorAgentClient', () => {
);
});
it('posts editor agent message streams and emits typed SSE events', async () => {
fetchWithApiAuthMock.mockResolvedValueOnce(
createSseResponse(
[
'event: stage',
'data: {"conversationId":"conversation-1","stage":"thinking"}',
'',
'event: message_delta',
'data: {"conversationId":"conversation-1","messageId":"assistant-1","role":"assistant","kind":"chat","textDelta":"我先看一下"}',
'',
'event: done',
'data: {"conversationId":"conversation-1","title":"生成角色"}',
'',
'',
].join('\n'),
),
);
const events: string[] = [];
it('sends an editor agent message and returns delta messages', async () => {
const responseBody = {
deltaMessages: [
{
id: 1,
role: 'assistant',
text: '我来处理',
attachments: [],
toolCall: null,
createdAt: '2026-07-03T00:00:00.000Z',
},
],
errorMessage: null,
};
requestJsonMock.mockResolvedValueOnce(responseBody);
await streamEditorAgentMessage(
'conversation-1',
{
clientMessageId: 'client-message-1',
text: '帮我把角色改成像素风',
attachments: [],
},
{
onEvent: (event) => events.push(event.event),
},
);
const result = await sendEditorAgentMessage('conversation-1', {
text: '帮我把角色改成像素风',
attachments: [],
});
expect(events).toEqual(['stage', 'message_delta', 'done']);
expect(fetchWithApiAuthMock).toHaveBeenCalledWith(
'/api/editor/agent-conversations/conversation-1/messages/stream',
expect(result).toEqual(responseBody);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/editor/agent-conversations/conversation-1/messages',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
clientMessageId: 'client-message-1',
text: '帮我把角色改成像素风',
attachments: [],
}),
}),
'发送画布 Agent 消息失败',
expect.objectContaining({
timeoutMs: 1_200_000,
authImpact: 'local',
}),
);
});
it('confirms and cancels a pending tool call by numeric message id', async () => {
const completedMessage = {
id: 7,
role: 'system',
text: 'internal completed tool output',
attachments: [],
toolCall: {
toolName: 'edit-image',
summary: '',
status: 'completed',
args: {},
images: [],
error: null,
},
createdAt: '2026-07-10T00:00:00.000Z',
};
const cancelledMessage = {
...completedMessage,
toolCall: {
...completedMessage.toolCall,
status: 'cancelled',
},
};
requestJsonMock
.mockResolvedValueOnce(completedMessage)
.mockResolvedValueOnce(cancelledMessage);
await expect(confirmEditorAgentToolCall('conversation/1', 7)).resolves.toBe(
completedMessage,
);
await expect(cancelEditorAgentToolCall('conversation/1', 7)).resolves.toBe(
cancelledMessage,
);
expect(requestJsonMock).toHaveBeenNthCalledWith(
1,
'/api/editor/agent-conversations/conversation%2F1/messages/7/confirm',
{ method: 'POST' },
'确认画布 Agent 操作失败',
{
timeoutMs: 1_200_000,
authImpact: 'local',
},
);
expect(requestJsonMock).toHaveBeenNthCalledWith(
2,
'/api/editor/agent-conversations/conversation%2F1/messages/7/cancel',
{ method: 'POST' },
'取消画布 Agent 操作失败',
{ authImpact: 'local' },
);
});
});
+51 -34
View File
@@ -4,23 +4,18 @@ import type {
EditorAgentConversationListResponse,
EditorAgentConversationResponse,
EditorAgentConversationSummary,
EditorAgentSseEvent,
StreamEditorAgentMessageRequest,
EditorAgentMessage,
EditorAgentMessageRequest,
EditorAgentMessageResponse,
} from '../../../packages/shared/src/contracts/editorAgent';
import {
appendApiErrorRequestId,
parseApiErrorMessage,
} from '../../../packages/shared/src/http';
import { fetchWithApiAuth, requestJson } from '../apiClient';
import { readEditorAgentSseEvents } from './editorAgentSse';
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_STREAM_TIMEOUT_MS = 1_200_000;
const EDITOR_AGENT_MESSAGE_TIMEOUT_MS = 1_200_000;
export type StreamEditorAgentMessageOptions = {
export type SendEditorAgentMessageOptions = {
signal?: AbortSignal;
onEvent?: (event: EditorAgentSseEvent) => void;
};
type DeleteEditorAgentConversationResponse = {
@@ -48,6 +43,16 @@ function agentConversationPath(conversationId: string) {
)}`;
}
function agentToolCallActionPath(
conversationId: string,
messageId: number,
action: 'confirm' | 'cancel',
) {
return `${agentConversationPath(conversationId)}/messages/${encodeURIComponent(
String(messageId),
)}/${action}`;
}
export async function listEditorAgentConversations(
projectId: string,
): Promise<EditorAgentConversationSummary[]> {
@@ -97,36 +102,48 @@ export async function deleteEditorAgentConversation(
return response.conversation;
}
export async function streamEditorAgentMessage(
export async function sendEditorAgentMessage(
conversationId: string,
payload: StreamEditorAgentMessageRequest,
options: StreamEditorAgentMessageOptions = {},
) {
const response = await fetchWithApiAuth(
`${agentConversationPath(conversationId)}/messages/stream`,
payload: EditorAgentMessageRequest,
options: SendEditorAgentMessageOptions = {},
): Promise<EditorAgentMessageResponse> {
return requestJson<EditorAgentMessageResponse>(
`${agentConversationPath(conversationId)}/messages`,
{
...jsonRequest('POST', payload as unknown as Record<string, unknown>),
signal: options.signal,
},
'发送画布 Agent 消息失败',
{
timeoutMs: EDITOR_AGENT_STREAM_TIMEOUT_MS,
timeoutMs: EDITOR_AGENT_MESSAGE_TIMEOUT_MS,
authImpact: 'local',
},
);
if (!response.ok) {
const responseText = await response.text();
throw new Error(
appendApiErrorRequestId(
parseApiErrorMessage(responseText, '发送画布 Agent 消息失败'),
response.headers.get('x-request-id'),
),
);
}
if (!response.body) {
throw new Error('streaming response body is unavailable');
}
await readEditorAgentSseEvents(response, (event) => options.onEvent?.(event));
}
export async function confirmEditorAgentToolCall(
conversationId: string,
messageId: number,
): Promise<EditorAgentMessage> {
return requestJson<EditorAgentMessage>(
agentToolCallActionPath(conversationId, messageId, 'confirm'),
{ method: 'POST' },
'确认画布 Agent 操作失败',
{
timeoutMs: EDITOR_AGENT_MESSAGE_TIMEOUT_MS,
authImpact: 'local',
},
);
}
export async function cancelEditorAgentToolCall(
conversationId: string,
messageId: number,
): Promise<EditorAgentMessage> {
return requestJson<EditorAgentMessage>(
agentToolCallActionPath(conversationId, messageId, 'cancel'),
{ method: 'POST' },
'取消画布 Agent 操作失败',
{ authImpact: 'local' },
);
}
@@ -1,88 +0,0 @@
import { expect, test, vi } from 'vitest';
import { readEditorAgentSseEvents } from './editorAgentSse';
function createSseResponse(chunks: string[]) {
const encoder = new TextEncoder();
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk));
}
controller.close();
},
}),
{
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
},
},
);
}
test('readEditorAgentSseEvents parses stage, generation result and done events', async () => {
const onEvent = vi.fn();
await readEditorAgentSseEvents(
createSseResponse([
'event: stage\r\ndata: {"conversationId":"conversation-1","stage":"generating"}\r\n\r\n',
'event: generation_result\r\ndata: {"conversationId":"conversation-1","messageId":"assistant-1","toolCallId":"tool-1","toolName":"generate_image","model":"gpt-image-2","images":[{"resourceId":"resource-1","imageSrc":"/generated/1.png","thumbnailSrc":null,"width":512,"height":512}]}\r\n\r\n',
'event: done\r\ndata: {"conversationId":"conversation-1","title":"像素角色"}\r\n\r\n',
]),
onEvent,
);
expect(onEvent).toHaveBeenNthCalledWith(1, {
event: 'stage',
data: {
conversationId: 'conversation-1',
stage: 'generating',
},
});
expect(onEvent).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
event: 'generation_result',
data: expect.objectContaining({
toolCallId: 'tool-1',
images: [
{
resourceId: 'resource-1',
imageSrc: '/generated/1.png',
thumbnailSrc: null,
width: 512,
height: 512,
},
],
}),
}),
);
expect(onEvent).toHaveBeenNthCalledWith(3, {
event: 'done',
data: {
conversationId: 'conversation-1',
title: '像素角色',
},
});
});
test('readEditorAgentSseEvents accepts fallback event name from message payload', async () => {
const onEvent = vi.fn();
await readEditorAgentSseEvents(
createSseResponse([
'data: {"event":"error","data":{"conversationId":"conversation-1","code":"INSUFFICIENT_BALANCE","message":"泥点不足","recoverable":true}}\n\n',
]),
onEvent,
);
expect(onEvent).toHaveBeenCalledWith({
event: 'error',
data: {
conversationId: 'conversation-1',
code: 'INSUFFICIENT_BALANCE',
message: '泥点不足',
recoverable: true,
},
});
});
@@ -1,70 +0,0 @@
import type { EditorAgentSseEvent } from '../../../packages/shared/src/contracts/editorAgent';
import { readSseJsonStream } from '../sseStream';
const EDITOR_AGENT_SSE_EVENT_NAMES = new Set<EditorAgentSseEvent['event']>([
'stage',
'message_delta',
'tool_started',
'tool_completed',
'generation_result',
'error',
'done',
]);
function isEditorAgentSseEventName(
value: unknown,
): value is EditorAgentSseEvent['event'] {
return (
typeof value === 'string' &&
EDITOR_AGENT_SSE_EVENT_NAMES.has(value as EditorAgentSseEvent['event'])
);
}
function readEventNameFromPayload(parsed: Record<string, unknown>) {
return isEditorAgentSseEventName(parsed.event) ? parsed.event : null;
}
function readEventDataFromPayload(parsed: Record<string, unknown>) {
const data = parsed.data;
return typeof data === 'object' && data !== null
? (data as Record<string, unknown>)
: null;
}
export function normalizeEditorAgentSseEvent(
eventName: string,
parsed: Record<string, unknown>,
): EditorAgentSseEvent | null {
const payloadEventName = readEventNameFromPayload(parsed);
if (payloadEventName) {
const payloadData = readEventDataFromPayload(parsed);
if (!payloadData) {
return null;
}
return {
event: payloadEventName,
data: payloadData,
} as unknown as EditorAgentSseEvent;
}
if (!isEditorAgentSseEventName(eventName)) {
return null;
}
return {
event: eventName,
data: parsed,
} as unknown as EditorAgentSseEvent;
}
export async function readEditorAgentSseEvents(
response: Response,
onEvent: (event: EditorAgentSseEvent) => void,
) {
await readSseJsonStream(response, ({ eventName, parsed }) => {
const event = normalizeEditorAgentSseEvent(eventName, parsed);
if (event) {
onEvent(event);
}
});
}