simplify confirm and cancel logic

This commit is contained in:
2026-07-15 19:57:08 +08:00
parent 3a30198fef
commit b8004a04e7
12 changed files with 233 additions and 267 deletions
@@ -84,6 +84,7 @@ npm run check:server-rs-ddd
- 完整消息文档存 OSS `editor-agent/{conversationId}.json`,由 `api-server` 负责 2 MiB 上限、会话内串行锁、读改写、SSE 事件持久化和 `touch` 元数据更新时间;该 JSON 不进入 `editor_canvas.layers_json`,也不作为画布布局真相。
- 对话附件只允许引用当前工程 `editor_project_resource` 或当前账号 `editor_asset` 的图片;前端可提交展示用 `imageSrc` / `thumbnailSrc`,后端必须按 `resourceId` / `assetId` 重新归一、校验 owner / project 和 `objectKey`,再给 LLM 或生成工具使用。
- 画布 Agent 工具复用既有编辑器图片生成 / 修改 / 图标 spritesheet BFF,并继续使用后端模型定价和 `execute_billable_asset_operation_with_cost`;前端不提交 `priceMudPoints`。
- `/messages/{messageId}/confirm` 与 `/messages/{messageId}/cancel` 只返回成功确认;前端成功后立即重新读取整个会话,以会话详情中的权威消息状态和 `externalJobId` 驱动气泡展示与任务轮询。
### 创作 / 游玩统一流程主干
@@ -563,7 +563,7 @@ pub async fn cancel_editor_agent_tool_call(
Ok(json_success_body(
Some(&request_context),
&document.messages[message_id],
json!({ "ok": true }),
))
}
@@ -606,7 +606,10 @@ pub async fn confirm_editor_agent_tool_call(
if tool_call.status != EditorAgentToolCallStatus::NotCompleted
|| tool_call.external_job_id.is_some()
{
return Ok(json_success_body(Some(&request_context), message));
return Ok(json_success_body(
Some(&request_context),
json!({ "ok": true }),
));
}
let tool_name = tool_call.tool_name.clone();
@@ -932,7 +935,7 @@ pub async fn confirm_editor_agent_tool_call(
write_messages_document(&state, &conversation, &document).await?;
Ok(json_success_body(
Some(&request_context),
&document.messages[message_id],
json!({ "ok": true }),
))
}
@@ -576,18 +576,35 @@ describe('EditorAgentConversationPanelView', () => {
it('shows structured pending details, hides system text and confirms once', async () => {
const client = createClient();
const pendingMessage = createPendingToolCallMessage();
vi.mocked(client.getConversation).mockResolvedValue({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
messages: [pendingMessage],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:10.000Z',
});
let resolveConfirmation: (message: EditorAgentMessage) => void = () => {};
const confirmedMessage: EditorAgentMessage = {
...pendingMessage,
text: 'internal completed tool output that must stay hidden',
toolCall: {
...pendingMessage.toolCall!,
externalJobId: 'task-edit-panel-1',
},
};
vi.mocked(client.getConversation)
.mockResolvedValueOnce({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
messages: [pendingMessage],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:10.000Z',
})
.mockResolvedValue({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
messages: [confirmedMessage],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:10.000Z',
});
let resolveConfirmation: () => void = () => {};
vi.mocked(client.confirmToolCall).mockImplementation(
() =>
new Promise<EditorAgentMessage>((resolve) => {
new Promise<void>((resolve) => {
resolveConfirmation = resolve;
}),
);
@@ -614,6 +631,8 @@ describe('EditorAgentConversationPanelView', () => {
expect(
screen.queryByText('internal system prompt that must stay hidden'),
).toBeNull();
const getConversationCallsBeforeConfirm = vi.mocked(client.getConversation)
.mock.calls.length;
fireEvent.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() => {
@@ -626,56 +645,51 @@ describe('EditorAgentConversationPanelView', () => {
);
await act(async () => {
resolveConfirmation({
...pendingMessage,
id: 2,
text: 'internal completed tool output that must stay hidden',
toolCall: {
...pendingMessage.toolCall!,
status: 'completed',
externalJobId: 'task-edit-panel-1',
images: [
{
resourceId: null,
objectKey: 'generated/result.png',
imageSrc: '/result.png',
thumbnailSrc: null,
width: 512,
height: 512,
},
],
},
});
resolveConfirmation();
});
expect(await screen.findByText('已完成')).toBeTruthy();
expect(await screen.findByText('执行中')).toBeTruthy();
expect(client.getConversation).toHaveBeenCalledTimes(
getConversationCallsBeforeConfirm + 1,
);
expect(
screen.queryByText(
'internal completed tool output that must stay hidden',
),
).toBeNull();
expect(onCanvasRefreshRequested).toHaveBeenCalledTimes(1);
expect(onCanvasRefreshRequested).not.toHaveBeenCalled();
expect(screen.queryByRole('button', { name: '确认' })).toBeNull();
});
it('cancels a pending tool call without exposing its system text', async () => {
const client = createClient();
const pendingMessage = createPendingToolCallMessage();
vi.mocked(client.getConversation).mockResolvedValue({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
messages: [pendingMessage],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:10.000Z',
});
vi.mocked(client.cancelToolCall).mockResolvedValue({
const cancelledMessage: EditorAgentMessage = {
...pendingMessage,
text: 'internal cancelled tool output that must stay hidden',
toolCall: {
...pendingMessage.toolCall!,
status: 'cancelled',
},
});
};
vi.mocked(client.getConversation)
.mockResolvedValueOnce({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
messages: [pendingMessage],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:10.000Z',
})
.mockResolvedValue({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
messages: [cancelledMessage],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:10.000Z',
});
vi.mocked(client.cancelToolCall).mockResolvedValue(undefined);
render(
<EditorAgentConversationPanelView
@@ -685,10 +699,16 @@ describe('EditorAgentConversationPanelView', () => {
/>,
);
fireEvent.click(await screen.findByRole('button', { name: '取消' }));
const cancelButton = await screen.findByRole('button', { name: '取消' });
const getConversationCallsBeforeCancel = vi.mocked(client.getConversation)
.mock.calls.length;
fireEvent.click(cancelButton);
expect(await screen.findByText('已取消')).toBeTruthy();
expect(client.cancelToolCall).toHaveBeenCalledWith('conversation-1', 2);
expect(client.getConversation).toHaveBeenCalledTimes(
getConversationCallsBeforeCancel + 1,
);
expect(
screen.queryByText(
'internal cancelled tool output that must stay hidden',
@@ -603,12 +603,8 @@ export function EditorAgentConversationPanelView({
? toolCallAction.action
: null
}
onConfirmToolCall={(messageId) => {
void confirmToolCall(messageId);
}}
onCancelToolCall={(messageId) => {
void cancelToolCall(messageId);
}}
onConfirmToolCall={confirmToolCall}
onCancelToolCall={cancelToolCall}
onJobCompleted={() => {
void refreshActiveConversation();
onCanvasRefreshRequested?.();
@@ -39,8 +39,8 @@ export function ThinkingBubble() {
type MessageBubbleProps = {
message: EditorAgentMessage;
busyAction: 'confirm' | 'cancel' | null;
onConfirmToolCall: (messageId: number) => void;
onCancelToolCall: (messageId: number) => void;
onConfirmToolCall: (messageId: number) => Promise<void>;
onCancelToolCall: (messageId: number) => Promise<void>;
onJobCompleted?: () => void;
};
@@ -73,6 +73,7 @@ export function MessageBubble({
const isUser = message.role === 'user';
const isSystem = message.role === 'system';
return (
<article
className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}
@@ -10,8 +10,8 @@ type PendingToolCallProps = {
messageId: number;
toolCall: EditorAgentToolCall;
busyAction: PendingToolCallAction;
onConfirm: (messageId: number) => void;
onCancel: (messageId: number) => void;
onConfirm: (messageId: number) => Promise<void>;
onCancel: (messageId: number) => Promise<void>;
};
function readString(value: unknown) {
@@ -28,6 +28,20 @@ export function PendingToolCall({
const displayArgs = toolCall.displayArgs;
const label = editorAgentToolLabel(toolCall.toolName);
const isBusy = busyAction !== null;
const statusLabel =
busyAction === 'confirm'
? '执行中'
: busyAction === 'cancel'
? '取消中'
: '待确认';
const handleCancel = () => {
void onCancel(messageId).catch(() => undefined);
};
const handleConfirm = () => {
void onConfirm(messageId).catch(() => undefined);
};
return (
<article className="flex justify-start" aria-label={`待确认的${label}操作`}>
@@ -36,7 +50,7 @@ export function PendingToolCall({
<Pencil className="h-4 w-4 shrink-0" aria-hidden="true" />
<span>{label}</span>
<span className="ml-auto text-xs font-normal text-amber-700">
待确认
{statusLabel}
</span>
</div>
@@ -106,7 +120,7 @@ export function PendingToolCall({
type="button"
className="inline-flex h-9 items-center justify-center gap-1.5 rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-600 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
disabled={isBusy}
onClick={() => onCancel(messageId)}
onClick={handleCancel}
>
{busyAction === 'cancel' ? (
<Loader2
@@ -122,7 +136,7 @@ export function PendingToolCall({
type="button"
className="inline-flex h-9 items-center justify-center gap-1.5 rounded-md bg-slate-900 px-3 text-sm font-medium text-white hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-50"
disabled={isBusy}
onClick={() => onConfirm(messageId)}
onClick={handleConfirm}
>
{busyAction === 'confirm' ? (
<Loader2
@@ -80,14 +80,16 @@ function ToolCallView({
const isCancelled = displayStatus === 'cancelled';
const isCompleted = displayStatus === 'completed';
const isFailed = displayStatus === 'failed';
const isExecuting = Boolean(jobId) && displayStatus === 'pending';
const isExecuting = displayStatus === 'pending' && Boolean(jobId);
const statusLabel = isCompleted
? '已完成'
: isFailed
? '失败'
: isCancelled
? '已取消'
: '待确认';
: isExecuting
? '执行中'
: '待确认';
return (
<div className="rounded-lg border border-slate-200 bg-white/80 p-2 text-xs text-slate-600">
<div className="flex items-center gap-2">
@@ -357,10 +357,10 @@ describe('useEditorAgentConversation', () => {
],
errorMessage: null,
} as EditorAgentMessageResponse);
vi.mocked(client.confirmToolCall).mockResolvedValue({
const confirmedMessage: EditorAgentMessage = {
id: 2,
role: 'system',
text: 'internal completed tool output',
text: 'internal confirmed tool output',
attachments: [],
toolCall: {
toolName: 'edit-image',
@@ -371,20 +371,12 @@ describe('useEditorAgentConversation', () => {
prompt: '换成像素风',
},
displayArgs: createEditImageDisplayArgs('换成像素风'),
images: [
{
resourceId: null,
objectKey: 'generated/result.png',
imageSrc: '/result.png',
thumbnailSrc: null,
width: 512,
height: 512,
},
],
images: [],
error: null,
},
createdAt: '2026-07-03T00:00:00.000Z',
});
};
vi.mocked(client.confirmToolCall).mockResolvedValue(undefined);
const onCanvasRefreshRequested = vi.fn();
const onConfirmSent = vi.fn();
const { result } = renderHook(() =>
@@ -410,6 +402,16 @@ describe('useEditorAgentConversation', () => {
expect(systemMsg?.role).toBe('system');
expect(systemMsg?.id).toBe(2);
expect(systemMsg?.toolCall?.externalJobId).toBeUndefined();
vi.mocked(client.getConversation).mockResolvedValue({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
messages: [result.current.messages[0]!, confirmedMessage],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:00.000Z',
});
const getConversationCallsBeforeConfirm = vi.mocked(client.getConversation)
.mock.calls.length;
const messageCount = result.current.messages.length;
await act(async () => {
@@ -417,18 +419,19 @@ describe('useEditorAgentConversation', () => {
});
expect(client.confirmToolCall).toHaveBeenCalledWith('conversation-1', 2);
expect(client.getConversation).toHaveBeenCalledTimes(
getConversationCallsBeforeConfirm + 1,
);
expect(result.current.messages).toHaveLength(messageCount);
expect(result.current.messages[1]?.toolCall?.status).toBe('not_completed');
expect(result.current.messages[1]?.toolCall?.externalJobId).toBe(
'task-edit-1',
);
expect(result.current.messages[1]?.toolCall?.images[0]?.objectKey).toBe(
'generated/result.png',
);
expect(onCanvasRefreshRequested).toHaveBeenCalledTimes(1);
expect(onCanvasRefreshRequested).not.toHaveBeenCalled();
expect(onConfirmSent).toHaveBeenCalledTimes(1);
});
it('keeps the action busy until confirmation returns an external job id', async () => {
it('keeps the action busy until confirmation succeeds', async () => {
const client = createClient();
const pendingMessage: EditorAgentMessage = {
id: 7,
@@ -445,27 +448,34 @@ describe('useEditorAgentConversation', () => {
},
createdAt: '2026-07-03T00:00:00.000Z',
};
const completedMessage: EditorAgentMessage = {
const confirmedMessage: EditorAgentMessage = {
...pendingMessage,
toolCall: {
...pendingMessage.toolCall!,
externalJobId: 'task-edit-2',
images: [],
},
};
let resolveConfirmation:
((message: EditorAgentMessage) => void) | undefined;
vi.mocked(client.getConversation).mockResolvedValue({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
messages: [pendingMessage],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:00.000Z',
});
let resolveConfirmation: (() => void) | undefined;
vi.mocked(client.getConversation)
.mockResolvedValueOnce({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
messages: [pendingMessage],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:00.000Z',
})
.mockResolvedValue({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
messages: [confirmedMessage],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:00.000Z',
});
vi.mocked(client.confirmToolCall).mockImplementation(
() =>
new Promise<EditorAgentMessage>((resolve) => {
new Promise<void>((resolve) => {
resolveConfirmation = resolve;
}),
);
@@ -479,8 +489,10 @@ describe('useEditorAgentConversation', () => {
result.current.messages[0]?.toolCall?.externalJobId,
).toBeUndefined();
});
const getConversationCallsBeforeConfirm = vi.mocked(client.getConversation)
.mock.calls.length;
let confirmationPromise: Promise<EditorAgentMessage | null> | undefined;
let confirmationPromise: Promise<null> | undefined;
act(() => {
confirmationPromise = result.current.confirmToolCall(7);
});
@@ -488,17 +500,19 @@ describe('useEditorAgentConversation', () => {
expect(client.confirmToolCall).toHaveBeenCalledWith('conversation-1', 7);
await act(async () => {
resolveConfirmation?.(completedMessage);
resolveConfirmation?.();
await confirmationPromise;
});
await waitFor(() => {
expect(result.current.messages[0]?.toolCall?.externalJobId).toBe(
'task-edit-2',
);
});
expect(result.current.toolCallAction).toBeNull();
expect(client.getConversation).toHaveBeenCalledTimes(
getConversationCallsBeforeConfirm + 1,
);
expect(result.current.messages[0]?.toolCall?.externalJobId).toBe(
'task-edit-2',
);
});
it('cancels a pending tool call and keeps the replacement in the same position', async () => {
it('cancels a pending tool call with the refreshed cancelled message', async () => {
const client = createClient();
const pendingMessage: EditorAgentMessage = {
id: 9,
@@ -515,22 +529,32 @@ describe('useEditorAgentConversation', () => {
},
createdAt: '2026-07-03T00:00:00.000Z',
};
vi.mocked(client.getConversation).mockResolvedValue({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
messages: [pendingMessage],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:00.000Z',
});
vi.mocked(client.cancelToolCall).mockResolvedValue({
const cancelledMessage: EditorAgentMessage = {
...pendingMessage,
text: 'internal cancelled tool output',
toolCall: {
...pendingMessage.toolCall!,
status: 'cancelled',
},
});
};
vi.mocked(client.cancelToolCall).mockResolvedValue(undefined);
vi.mocked(client.getConversation)
.mockResolvedValueOnce({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
messages: [pendingMessage],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:00.000Z',
})
.mockResolvedValue({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
messages: [cancelledMessage],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:00.000Z',
});
const onCanvasRefreshRequested = vi.fn();
const { result } = renderHook(() =>
useEditorAgentConversation({
@@ -543,11 +567,16 @@ describe('useEditorAgentConversation', () => {
await waitFor(() => {
expect(result.current.messages[0]?.toolCall?.status).toBe('not_completed');
});
const getConversationCallsBeforeCancel = vi.mocked(client.getConversation)
.mock.calls.length;
await act(async () => {
await result.current.cancelToolCall(9);
});
expect(client.cancelToolCall).toHaveBeenCalledWith('conversation-1', 9);
expect(client.getConversation).toHaveBeenCalledTimes(
getConversationCallsBeforeCancel + 1,
);
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0]?.toolCall?.status).toBe('cancelled');
expect(onCanvasRefreshRequested).not.toHaveBeenCalled();
@@ -42,11 +42,11 @@ export type EditorAgentConversationClient = {
confirmToolCall: (
conversationId: string,
messageId: number,
) => Promise<EditorAgentMessage>;
) => Promise<void>;
cancelToolCall: (
conversationId: string,
messageId: number,
) => Promise<EditorAgentMessage>;
) => Promise<void>;
};
type UseEditorAgentConversationOptions = {
@@ -435,26 +435,22 @@ export function useEditorAgentConversation({
setErrorMessage(null);
try {
const updatedMessage = await (action === 'confirm'
await (action === 'confirm'
? client.confirmToolCall(conversationId, messageId)
: client.cancelToolCall(conversationId, messageId));
if (activeConversationIdRef.current !== conversationId) {
return updatedMessage;
return null;
}
setMessages((currentMessages) =>
currentMessages.map((message) =>
message.id === messageId ? updatedMessage : message,
),
const detail = await loadConversation(conversationId);
const updatedMessage = detail.messages.find(
(message) => message.id === messageId,
);
if (action === 'confirm') {
requestCanvasRefreshForMessages([updatedMessage]);
if (updatedMessage.toolCall?.externalJobId) {
onConfirmSent?.();
}
if (action === 'confirm' && updatedMessage?.toolCall?.externalJobId) {
onConfirmSent?.();
}
return updatedMessage;
return null;
} catch (error) {
if (activeConversationIdRef.current === conversationId) {
setErrorMessage(
@@ -464,10 +460,8 @@ export function useEditorAgentConversation({
? '确认画布 Agent 操作失败'
: '取消画布 Agent 操作失败',
);
// The server may have accepted the action even when its response was lost.
void loadConversation(conversationId).catch(() => undefined);
}
return null;
throw error;
} finally {
if (activeToolCallActionRef.current === nextAction) {
activeToolCallActionRef.current = null;
@@ -475,7 +469,7 @@ export function useEditorAgentConversation({
}
}
},
[client, loadConversation, onConfirmSent, requestCanvasRefreshForMessages],
[client, loadConversation, onConfirmSent],
);
const confirmToolCall = useCallback(
@@ -2106,39 +2106,12 @@ describe('ImageCanvasEditorView', () => {
expect(loadEditorProjectMock).not.toHaveBeenCalled();
});
it('refreshes canvas and asset library when Agent generation finishes', async () => {
it('confirms Agent tool calls and refreshes the conversation for the job id', async () => {
enableEditorAgentSidebarForTest();
let showGeneratedAsset = false;
loadEditorAssetLibraryMock.mockImplementation(async () => ({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: showGeneratedAsset
? [
...defaultEditorAssetLibraryAssets,
{
assetId: 'asset-agent-stream',
folderId: 'project',
label: 'Agent结果素材',
imageSrc: '/agent-stream.png',
width: 512,
height: 512,
sourceType: 'generated',
assetKind: 'editor_agent_generated_image',
},
]
: defaultEditorAssetLibraryAssets,
}));
listEditorAgentConversationsMock.mockResolvedValueOnce([
createEditorAgentConversationSummary(),
]);
getEditorAgentConversationMock.mockResolvedValueOnce({
const pendingConversation: EditorAgentConversationDetail = {
...createEditorAgentConversationSummary(),
title: '画布 Agent',
createdAt: '2026-07-03T00:00:00.000Z',
@@ -2163,73 +2136,33 @@ describe('ImageCanvasEditorView', () => {
createdAt: '2026-07-03T00:00:00.000Z',
},
],
});
confirmEditorAgentToolCallMock.mockImplementation(async () => {
showGeneratedAsset = true;
return {
id: 3,
role: 'system',
text: 'internal completed tool output',
attachments: [],
toolCall: {
toolName: 'edit-image',
status: 'completed',
externalJobId: 'task-edit-canvas-1',
args: {},
displayArgs: createEditImageToolCallDisplayArgs('把图片换成像素风'),
images: [
{
resourceId: null,
imageSrc: '/agent-stream.png',
thumbnailSrc: null,
width: 512,
height: 512,
},
],
error: null,
},
createdAt: '2026-07-03T00:00:00.000Z',
};
});
loadEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-default',
title: '默认项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
...defaultEditorProjectLayers,
};
const confirmedConversation: EditorAgentConversationDetail = {
...pendingConversation,
messages: [
{
layerId: 'layer-agent-stream',
resourceId: 'resource-agent-stream',
title: 'Agent结果图',
src: '/agent-stream.png',
x: 1500,
y: 300,
width: 512,
height: 512,
originalWidth: 512,
originalHeight: 512,
zIndex: 3,
sourceType: 'generated',
id: 3,
role: 'system',
text: 'internal confirmed tool output',
attachments: [],
toolCall: {
toolName: 'edit-image',
status: 'not_completed',
externalJobId: 'task-edit-canvas-1',
args: {},
displayArgs:
createEditImageToolCallDisplayArgs('把图片换成像素风'),
images: [],
error: null,
},
createdAt: '2026-07-03T00:00:00.000Z',
},
],
resources: [
...defaultEditorProjectResources,
{
resourceId: 'resource-agent-stream',
projectId: 'editor-project-default',
imageSrc: '/agent-stream.png',
width: 512,
height: 512,
sourceType: 'generated',
prompt: 'Agent生成结果',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-agent-stream',
assetKind: 'editor_agent_generated_image',
},
],
updatedAt: '2026-07-03T00:02:00.000Z',
});
};
getEditorAgentConversationMock
.mockResolvedValueOnce(pendingConversation)
.mockResolvedValue(confirmedConversation);
confirmEditorAgentToolCallMock.mockResolvedValue(undefined);
render(<ImageCanvasEditorView />);
@@ -2239,6 +2172,8 @@ describe('ImageCanvasEditorView', () => {
'editor-agent-conv-test',
);
});
const getConversationCallsBeforeConfirm =
getEditorAgentConversationMock.mock.calls.length;
fireEvent.click(await screen.findByRole('button', { name: '确认' }));
await waitFor(() => {
@@ -2246,14 +2181,12 @@ describe('ImageCanvasEditorView', () => {
'editor-agent-conv-test',
3,
);
expect(loadEditorProjectMock).toHaveBeenCalledWith(
'editor-project-default',
);
});
expect(await screen.findByAltText('画布图片:Agent结果图')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '打开素材' }));
expect(await screen.findByText('Agent结果素材')).toBeTruthy();
expect(getEditorAgentConversationMock).toHaveBeenCalledTimes(
getConversationCallsBeforeConfirm + 1,
);
expect(await screen.findByText('执行中')).toBeTruthy();
expect(screen.queryByAltText('画布图片:Agent结果图')).toBeNull();
});
it('resets the canvas view without forwarding the click event to fit layers', () => {
@@ -147,42 +147,16 @@ describe('editorAgentClient', () => {
});
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',
status: 'completed',
args: {},
displayArgs: {
stringArgs: [],
imageArgs: [],
extras: { priceMudPoints: 0 },
},
images: [],
error: null,
},
createdAt: '2026-07-10T00:00:00.000Z',
};
const cancelledMessage = {
...completedMessage,
toolCall: {
...completedMessage.toolCall,
status: 'cancelled',
},
};
requestJsonMock
.mockResolvedValueOnce(completedMessage)
.mockResolvedValueOnce(cancelledMessage);
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({ ok: true });
await expect(confirmEditorAgentToolCall('conversation/1', 7)).resolves.toBe(
completedMessage,
);
await expect(cancelEditorAgentToolCall('conversation/1', 7)).resolves.toBe(
cancelledMessage,
);
await expect(
confirmEditorAgentToolCall('conversation/1', 7),
).resolves.toBeUndefined();
await expect(
cancelEditorAgentToolCall('conversation/1', 7),
).resolves.toBeUndefined();
expect(requestJsonMock).toHaveBeenNthCalledWith(
1,
@@ -4,7 +4,6 @@ import type {
EditorAgentConversationListResponse,
EditorAgentConversationResponse,
EditorAgentConversationSummary,
EditorAgentMessage,
EditorAgentMessageRequest,
EditorAgentMessageResponse,
} from '../../../packages/shared/src/contracts/editorAgent';
@@ -124,8 +123,8 @@ export async function sendEditorAgentMessage(
export async function confirmEditorAgentToolCall(
conversationId: string,
messageId: number,
): Promise<EditorAgentMessage> {
return requestJson<EditorAgentMessage>(
): Promise<void> {
await requestJson<unknown>(
agentToolCallActionPath(conversationId, messageId, 'confirm'),
{ method: 'POST' },
'确认画布 Agent 操作失败',
@@ -139,8 +138,8 @@ export async function confirmEditorAgentToolCall(
export async function cancelEditorAgentToolCall(
conversationId: string,
messageId: number,
): Promise<EditorAgentMessage> {
return requestJson<EditorAgentMessage>(
): Promise<void> {
await requestJson<unknown>(
agentToolCallActionPath(conversationId, messageId, 'cancel'),
{ method: 'POST' },
'取消画布 Agent 操作失败',