simplify confirm and cancel logic
This commit is contained in:
@@ -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 }),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
+63
-43
@@ -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',
|
||||
|
||||
+2
-6
@@ -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">
|
||||
|
||||
+78
-49
@@ -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 操作失败',
|
||||
|
||||
Reference in New Issue
Block a user