diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRunService.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRunService.java index 08e18a65..1dff350c 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRunService.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRunService.java @@ -1493,6 +1493,7 @@ public class AgentRunService { payload.put("toolCallId", toolCallId); } if (Boolean.TRUE.equals(event.getMetadata().get("asyncTool"))) { + putIfPresent(payload, "sourceToolCallId", toolCallId); enrichAsyncToolPayload(payload, event.getMetadata(), toolCallId); String taskId = stringValue(payload, "taskId"); if (taskId != null && !taskId.isBlank()) { @@ -1528,10 +1529,12 @@ public class AgentRunService { private Map buildAsyncToolEventPayload(AgentRuntimeEvent event) { Map payload = new LinkedHashMap<>(event.getPayload() == null ? Map.of() : event.getPayload()); String taskId = stringValue(payload, "taskId"); - String toolCallId = firstText(taskId, event.getToolCallId()); + String sourceToolCallId = event.getToolCallId(); + String toolCallId = firstText(taskId, sourceToolCallId); if (toolCallId != null && !toolCallId.isBlank()) { payload.put("toolCallId", toolCallId); } + putIfPresent(payload, "sourceToolCallId", sourceToolCallId); enrichAsyncToolPayload(payload, event.getMetadata(), toolCallId); return payload; } @@ -1540,8 +1543,8 @@ public class AgentRunService { Map safeMetadata = metadata == null ? Map.of() : metadata; payload.put("asyncTool", true); putIfPresent(payload, "asyncToolName", firstText(stringValue(payload, "asyncToolName"), stringValue(safeMetadata, "asyncToolName"))); - putIfPresent(payload, "phase", firstText(stringValue(payload, "phase"), stringValue(safeMetadata, "asyncToolPhase"))); - putIfPresent(payload, "taskId", firstText(stringValue(payload, "taskId"), stringValue(safeMetadata, "taskId"))); + putIfPresent(payload, "phase", firstText(stringValue(safeMetadata, "asyncToolPhase"), stringValue(payload, "phase"))); + putIfPresent(payload, "taskId", resolveAsyncTaskId(payload, safeMetadata)); putIfPresent(payload, "status", firstText(stringValue(payload, "status"), stringValue(safeMetadata, "status"))); String displayName = firstText(stringValue(payload, "toolDisplayName"), firstText(stringValue(safeMetadata, "toolDisplayName"), stringValue(payload, "asyncToolName"))); @@ -1553,6 +1556,22 @@ public class AgentRunService { payload.put("label", asyncToolLabel(stringValue(payload, "status"), stringValue(payload, "phase"), displayName)); } + /** + * 从异步事件载荷、结果元数据或阶段调用入参中解析稳定任务 ID。 + * + * @param payload 事件载荷 + * @param metadata 事件元数据 + * @return 异步任务 ID;不存在时返回 null + */ + private String resolveAsyncTaskId(Map payload, Map metadata) { + String taskId = firstText(stringValue(payload, "taskId"), stringValue(metadata, "taskId")); + if (taskId != null && !taskId.isBlank()) { + return taskId; + } + Map input = mapPayload(firstNonNull(payload.get("input"), payload.get("toolInput"))); + return firstText(stringValue(input, "taskId"), stringValue(input, "task_id")); + } + private String asyncToolLabel(String status, String phase, String displayName) { String name = displayName == null || displayName.isBlank() ? "异步工具" : displayName; if ("SUCCEEDED".equalsIgnoreCase(status)) { diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentRunServiceDraftAndHitlTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentRunServiceDraftAndHitlTest.java index 72f6afa7..4ddbb57e 100644 --- a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentRunServiceDraftAndHitlTest.java +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentRunServiceDraftAndHitlTest.java @@ -575,6 +575,56 @@ public class AgentRunServiceDraftAndHitlTest { Assert.assertEquals(1, recorder.recordCount); } + /** + * 验证异步轮询调用会暴露原始调用 ID,并从入参解析稳定任务 ID。 + * + * @throws Exception 反射调用失败时抛出 + */ + @Test + public void buildToolEventPayloadShouldExposeStableAsyncTaskIdentity() throws Exception { + AgentRunService service = new AgentRunService(); + AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_CALL); + event.setToolCallId("observe-call-2"); + event.getPayload().put("input", Map.of("taskId", "task-1")); + event.getPayload().put("phase", "PRE_ACTING"); + event.getMetadata().put("asyncTool", true); + event.getMetadata().put("asyncToolName", "document_generation"); + event.getMetadata().put("asyncToolPhase", "observe"); + + Map payload = invoke(service, "buildToolEventPayload", + new Class[]{AgentRuntimeEvent.class}, event); + + Assert.assertEquals("observe-call-2", payload.get("sourceToolCallId")); + Assert.assertEquals("task-1", payload.get("taskId")); + Assert.assertEquals("task-1", payload.get("toolCallId")); + Assert.assertEquals("observe", payload.get("phase")); + } + + /** + * 验证异步轮询结果会从结果元数据恢复稳定任务 ID。 + * + * @throws Exception 反射调用失败时抛出 + */ + @Test + public void buildToolEventPayloadShouldResolveAsyncTaskIdentityFromResultMetadata() throws Exception { + AgentRunService service = new AgentRunService(); + AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_RESULT); + event.setToolCallId("observe-call-2"); + event.getPayload().put("phase", "POST_ACTING"); + event.getMetadata().put("asyncTool", true); + event.getMetadata().put("asyncToolName", "document_generation"); + event.getMetadata().put("asyncToolPhase", "observe"); + event.getMetadata().put("taskId", "task-1"); + + Map payload = invoke(service, "buildToolEventPayload", + new Class[]{AgentRuntimeEvent.class}, event); + + Assert.assertEquals("observe-call-2", payload.get("sourceToolCallId")); + Assert.assertEquals("task-1", payload.get("taskId")); + Assert.assertEquals("task-1", payload.get("toolCallId")); + Assert.assertEquals("observe", payload.get("phase")); + } + /** * 验证草稿工具审批只注册内存恢复令牌,不写 HITL pending 表。 * diff --git a/easyflow-ui-admin/app/src/views/ai/agent-chat/adapters/agentTimelineAdapter.test.ts b/easyflow-ui-admin/app/src/views/ai/agent-chat/adapters/agentTimelineAdapter.test.ts index 0008aa7c..32af49e8 100644 --- a/easyflow-ui-admin/app/src/views/ai/agent-chat/adapters/agentTimelineAdapter.test.ts +++ b/easyflow-ui-admin/app/src/views/ai/agent-chat/adapters/agentTimelineAdapter.test.ts @@ -336,6 +336,74 @@ describe('agentTimelineAdapter', () => { expect(error?.message).toBe('失败'); }); + it('keeps async workflow polling events in the original approval card', () => { + const items: any[] = []; + + applyAgentSseEnvelope(items, { + domain: 'TOOL', + type: 'FORM_REQUEST', + payload: { + input: { user_input: '写一篇小作文' }, + requestId: 'req-async', + resumeToken: 'token-async', + toolCallId: 'submit-call-1', + toolName: '文档生成', + }, + }); + applyAgentSseEnvelope(items, { + domain: 'TOOL', + type: 'TOOL_RESULT', + payload: { + asyncTool: true, + phase: 'submit', + sourceToolCallId: 'submit-call-1', + status: 'RUNNING', + taskId: 'task-1', + toolCallId: 'task-1', + toolName: '文档生成', + }, + }); + for (const sourceToolCallId of ['observe-call-1', 'observe-call-2']) { + applyAgentSseEnvelope(items, { + domain: 'TOOL', + type: 'TOOL_CALL', + payload: { + asyncTool: true, + input: { taskId: 'task-1' }, + phase: 'observe', + sourceToolCallId, + status: 'RUNNING', + taskId: 'task-1', + toolCallId: 'task-1', + toolName: '文档生成', + }, + }); + } + applyAgentSseEnvelope(items, { + domain: 'TOOL', + type: 'TOOL_RESULT', + payload: { + asyncTool: true, + phase: 'result', + sourceToolCallId: 'result-call-1', + status: 'SUCCEEDED', + taskId: 'task-1', + toolCallId: 'task-1', + toolName: '文档生成', + }, + }); + + const tools = items.filter((item) => item.type === 'tool'); + expect(tools).toHaveLength(1); + expect(tools[0]).toMatchObject({ + mode: 'approval', + status: 'success', + taskId: 'task-1', + toolCallId: 'task-1', + toolName: '文档生成', + }); + }); + it('keeps assistant text and approval card when a tool request is rejected', () => { const items: any[] = []; diff --git a/easyflow-ui-admin/app/src/views/ai/agent-chat/adapters/agentTimelineAdapter.ts b/easyflow-ui-admin/app/src/views/ai/agent-chat/adapters/agentTimelineAdapter.ts index 53422bc5..c67d3af9 100644 --- a/easyflow-ui-admin/app/src/views/ai/agent-chat/adapters/agentTimelineAdapter.ts +++ b/easyflow-ui-admin/app/src/views/ai/agent-chat/adapters/agentTimelineAdapter.ts @@ -463,6 +463,7 @@ export function applyAgentSseEnvelope( } if (domain === 'TOOL' && (type === 'TOOL_CALL' || type === 'TOOL_RESULT')) { const asyncTool = payload.asyncTool === true; + const taskInput = asRecord(payload.input ?? payload.toolInput); const toolName = normalizeToolName( payload.toolDisplayName ?? payload.toolName ?? payload.name, ); @@ -485,6 +486,12 @@ export function applyAgentSseEnvelope( metadata, 'knowledge-retrieval', ), + sourceToolCallId: asyncTool + ? asText(payload.sourceToolCallId ?? payload.source_tool_call_id) + : undefined, + taskId: asyncTool + ? asText(payload.taskId ?? taskInput.taskId ?? taskInput.task_id) + : undefined, toolCallId: asyncTool ? asText(payload.toolCallId ?? payload.taskId ?? payload.id) : normalizeToolCallId(payload),