diff --git a/server-rs/crates/api-server/src/editor_agent/agent.rs b/server-rs/crates/api-server/src/editor_agent/agent.rs index 707106898..98709e806 100644 --- a/server-rs/crates/api-server/src/editor_agent/agent.rs +++ b/server-rs/crates/api-server/src/editor_agent/agent.rs @@ -172,34 +172,3 @@ fn build_tools_system_prompt(base_prompt: &str, tool_specs: &[ToolPromptSpec]) - prompt } - -// -// async fn run_chat_agent(config: LlmConfig) -> Result<(), LlmError> { -// let client = LlmClient::new(config)?; -// let agent = LlmChatAgentBuilder::new() -// .with_client(client) -// .system_prompt("You are a helpful assistant with an echo tool.") -// .build(); -// -// let names = agent -// .tools() -// .iter() -// .map(|t| t.tool_name().to_string()) -// .collect(); -// let tool_hook = ToolValidationHook::new(names.clone()); -// -// let output = agent -// .prompt(LlmMessage::user( -// "Use the echo tool to echo 'Hello from the JSON harness!', then tell me what it said.", -// )) -// .max_turns(5) -// .add_hook(tool_hook) -// .await -// .expect("agent should succeed"); -// -// println!("--- Final Agent Response ---"); -// println!("{}", output.text); -// println!("-----------------------------"); -// -// Ok(()) -// } diff --git a/server-rs/crates/api-server/src/editor_agent/api.rs b/server-rs/crates/api-server/src/editor_agent/api.rs index 49d7b280f..251e5c378 100644 --- a/server-rs/crates/api-server/src/editor_agent/api.rs +++ b/server-rs/crates/api-server/src/editor_agent/api.rs @@ -481,15 +481,15 @@ pub async fn confirm_editor_agent_tool_call( .with_details(json!({ "message": "message not found" }))); } - let msg = &document.messages[message_id]; + let msg = &mut document.messages[message_id]; - // Validate role and tool_call + // A persisted executing state prevents a repeated confirmation from starting a second job. if msg.role != EditorAgentMessageRole::System { return Err(editor_agent_bad_request("message is not a system message")); } let tc = msg .tool_call - .as_ref() + .as_mut() .ok_or_else(|| editor_agent_bad_request("message has no tool call"))?; if tc.status != EditorAgentToolCallStatus::PendingConfirmation { return Err(editor_agent_bad_request( @@ -497,10 +497,13 @@ pub async fn confirm_editor_agent_tool_call( )); } + let tool_name = tc.tool_name.clone(); + let tool_args = tc.args.clone(); + // TODO dont use match - match tc.tool_name.as_str() { + match tool_name.as_str() { EditImageTool::NAME => { - let args: EditImageToolArgs = serde_json::from_value(tc.args.clone()) + let args: EditImageToolArgs = serde_json::from_value(tool_args) .map_err(|e| editor_agent_bad_request(format!("invalid tool call args: {e}")))?; // Get project @@ -533,6 +536,13 @@ pub async fn confirm_editor_agent_tool_call( let edit_tool = EditImageTool { context: tool_context, }; + + // mark it quickly + if let Some(tool_call) = &mut document.messages[message_id].tool_call { + tool_call.status = EditorAgentToolCallStatus::Executing; + } + write_messages_document(&state, &conversation, &document).await?; + let result = edit_tool .execute( &state, @@ -546,7 +556,21 @@ pub async fn confirm_editor_agent_tool_call( None, // source_resource_id Some(canvas_completion), ) - .await?; + .await; + + let result = match result { + Ok(result) => result, + Err(error) => { + let msg = &mut document.messages[message_id]; + msg.text = format!("[tool_call:{tool_name}] output: {error}"); + if let Some(tool_call) = &mut msg.tool_call { + tool_call.status = EditorAgentToolCallStatus::Failed; + tool_call.error = Some(error.to_string()); + } + write_messages_document(&state, &conversation, &document).await?; + return Err(error); + } + }; // Build generated images let generated_images = vec![EditorAgentGeneratedImage { @@ -579,7 +603,7 @@ pub async fn confirm_editor_agent_tool_call( } _ => Err(editor_agent_bad_request(format!( "unsupported tool: {}", - tc.tool_name + tool_name ))), } } diff --git a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx index d26c58b84..7b2296344 100644 --- a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx @@ -3,7 +3,10 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { EditorAgentMessageResponse } from '../../../../packages/shared/src/contracts/editorAgent.ts'; +import type { + EditorAgentMessage, + EditorAgentMessageResponse, +} from '../../../../packages/shared/src/contracts/editorAgent.ts'; import { type EditorAgentConversationClient, useEditorAgentConversation, @@ -305,6 +308,74 @@ describe('useEditorAgentConversation', () => { expect(onCanvasRefreshRequested).toHaveBeenCalledTimes(1); }); + it('marks a confirmation as executing before the request finishes', async () => { + const client = createClient(); + const pendingMessage: EditorAgentMessage = { + id: 0, + role: 'system', + text: '需要生成一张图', + attachments: [], + toolCall: { + toolName: 'edit-image', + summary: '', + status: 'pending_confirmation', + args: { object_image_id: 'source-image', prompt: '换成像素风' }, + images: [], + error: null, + }, + createdAt: '2026-07-03T00:00:00.000Z', + }; + const completedMessage: EditorAgentMessage = { + ...pendingMessage, + toolCall: { + ...pendingMessage.toolCall!, + status: 'completed', + 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', + }); + vi.mocked(client.confirmToolCall).mockImplementation( + () => + new Promise((resolve) => { + resolveConfirmation = resolve; + }), + ); + const { result } = renderHook(() => + useEditorAgentConversation({ projectId: 'project-1', client }), + ); + + await waitFor(() => { + expect(result.current.messages[0]?.toolCall?.status).toBe( + 'pending_confirmation', + ); + }); + + act(() => { + void result.current.confirmToolCall(0); + }); + + await waitFor(() => { + expect(result.current.messages[0]?.toolCall?.status).toBe('executing'); + }); + await result.current.confirmToolCall(0); + expect(client.confirmToolCall).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveConfirmation?.(completedMessage); + }); + await waitFor(() => { + expect(result.current.messages[0]?.toolCall?.status).toBe('completed'); + }); + }); + it('cancels a pending tool call and keeps the replacement in the same position', async () => { const client = createClient(); const pendingMessage = { diff --git a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts index 1d85a0f2a..89a59a80d 100644 --- a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts +++ b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts @@ -409,6 +409,23 @@ export function useEditorAgentConversation({ setToolCallAction(nextAction); setErrorMessage(null); + if (action === 'confirm') { + setMessages((currentMessages) => + currentMessages.map((message) => + message.id === messageId && + message.toolCall?.status === 'pending_confirmation' + ? { + ...message, + toolCall: { + ...message.toolCall, + status: 'executing', + }, + } + : message, + ), + ); + } + try { const updatedMessage = await (action === 'confirm' ? client.confirmToolCall(conversationId, messageId) @@ -436,6 +453,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; } finally { @@ -445,7 +464,7 @@ export function useEditorAgentConversation({ } } }, - [client, requestCanvasRefreshForMessages], + [client, loadConversation, requestCanvasRefreshForMessages], ); const confirmToolCall = useCallback(