fix: 归并异步工具状态卡片

- 统一异步阶段事件的稳定任务标识

- 保留原始工具调用 ID 供事件关联

- 将提交、轮询和结果状态更新到同一审批卡片
This commit is contained in:
2026-07-23 19:48:44 +08:00
parent 4a8e633083
commit 8b4ba65e1c
4 changed files with 147 additions and 3 deletions

View File

@@ -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<String, Object> buildAsyncToolEventPayload(AgentRuntimeEvent event) {
Map<String, Object> 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<String, Object> 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<String, Object> payload, Map<String, Object> metadata) {
String taskId = firstText(stringValue(payload, "taskId"), stringValue(metadata, "taskId"));
if (taskId != null && !taskId.isBlank()) {
return taskId;
}
Map<String, Object> 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)) {

View File

@@ -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<String, Object> 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<String, Object> 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 表。
*

View File

@@ -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[] = [];

View File

@@ -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),