diff --git a/mateclaw-server/src/main/java/vip/mate/agent/GraphEventPublisher.java b/mateclaw-server/src/main/java/vip/mate/agent/GraphEventPublisher.java
index 6a98706c..db5165f9 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/GraphEventPublisher.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/GraphEventPublisher.java
@@ -52,8 +52,23 @@ public final class GraphEventPublisher {
}
public static GraphEvent toolStart(String toolName, String arguments) {
+ return toolStart(null, toolName, arguments);
+ }
+
+ /**
+ * Emit a tool_call_started event with the LLM-provided tool_call.id so the
+ * frontend can match start/complete pairs precisely. Without the id, the
+ * UI uses toolName + status="running" + findLast() to pair completes back
+ * to the original card; when the LLM fires multiple calls of the same tool
+ * (e.g. several execute_shell_command in a row) the matching collapses to
+ * "the most recent running" and earlier cards get stranded with a
+ * permanent spinner. Pass the id whenever it's available; null is OK for
+ * legacy callers.
+ */
+ public static GraphEvent toolStart(String toolCallId, String toolName, String arguments) {
long ts = System.currentTimeMillis();
return new GraphEvent(EVENT_TOOL_START, Map.of(
+ "toolCallId", toolCallId != null ? toolCallId : "",
"toolName", toolName,
"arguments", arguments != null ? arguments : "",
"timestamp", ts
@@ -61,8 +76,13 @@ public final class GraphEventPublisher {
}
public static GraphEvent toolComplete(String toolName, String result, boolean success) {
+ return toolComplete(null, toolName, result, success);
+ }
+
+ public static GraphEvent toolComplete(String toolCallId, String toolName, String result, boolean success) {
long ts = System.currentTimeMillis();
return new GraphEvent(EVENT_TOOL_COMPLETE, Map.of(
+ "toolCallId", toolCallId != null ? toolCallId : "",
"toolName", toolName,
"result", result != null ? truncateResult(result) : "",
"success", success,
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java
index c3a7a15a..869bd574 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java
@@ -232,7 +232,7 @@ public class ToolExecutionExecutor {
String toolName = toolCall.name();
String arguments = toolCall.arguments();
- events.add(GraphEventPublisher.toolStart(toolName, arguments));
+ events.add(GraphEventPublisher.toolStart(toolCall.id(), toolName, arguments));
// 0. 子会话工具拦截:委派上下文中的子 Agent 禁止调用特定工具
if (vip.mate.tool.builtin.DelegationContext.currentDepth() > 0) {
@@ -240,7 +240,7 @@ public class ToolExecutionExecutor {
if (denied.contains(toolName)) {
String msg = "[安全限制] 子 Agent 不允许使用工具: " + toolName;
log.info("[ToolExecutor] Child agent blocked from using tool: {}", toolName);
- events.add(GraphEventPublisher.toolComplete(toolName, msg, false));
+ events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false));
allResponses.add(new org.springframework.ai.chat.messages.ToolResponseMessage.ToolResponse(
toolCall.id(), toolName, msg));
continue;
@@ -255,7 +255,7 @@ public class ToolExecutionExecutor {
log.warn("[ToolExecutor] Tool {} arguments invalid/truncated JSON (len={}): {}",
toolName, arguments.length(), jsonEx.getMessage());
String truncationError = normalizeToolExecutionError(jsonEx);
- events.add(GraphEventPublisher.toolComplete(toolName, truncationError, false));
+ events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, truncationError, false));
allResponses.add(new ToolResponseMessage.ToolResponse(
toolCall.id(), toolName, truncationError));
continue;
@@ -300,7 +300,7 @@ public class ToolExecutionExecutor {
ToolCallback callback = toolCallbackMap.get(toolName);
if (callback == null) {
log.warn("[ToolExecutor] Tool not found: {}", toolName);
- events.add(GraphEventPublisher.toolComplete(toolName, "Tool not found: " + toolName, false));
+ events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, "Tool not found: " + toolName, false));
allResponses.add(new ToolResponseMessage.ToolResponse(
toolCall.id(), toolName, "Tool not found: " + toolName));
continue;
@@ -376,7 +376,7 @@ public class ToolExecutionExecutor {
ToolCallback callback = toolCallbackMap.get(toolName);
if (callback == null) {
log.warn("[ToolExecutor] Pre-approved tool not found: {}", toolName);
- events.add(GraphEventPublisher.toolComplete(toolName, "Tool not found: " + toolName, false));
+ events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, "Tool not found: " + toolName, false));
return new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, "Tool not found: " + toolName);
}
@@ -414,7 +414,7 @@ public class ToolExecutionExecutor {
result = truncateToolResult(result, MAX_TOOL_RESULT_CHARS);
log.info("[ToolExecutor] Pre-approved tool {} returned {} chars{}", toolName, rawLen,
result != null && result.length() < rawLen ? " (now " + result.length() + " after spill/truncate)" : "");
- events.add(GraphEventPublisher.toolComplete(toolName, result, true));
+ events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, result, true));
return new ToolResponseMessage.ToolResponse(
toolCall.id(), toolName, result != null ? result : "");
} catch (Exception e) {
@@ -422,7 +422,7 @@ public class ToolExecutionExecutor {
String safeError = isReturnDirect(callback)
? "Tool execution failed (details withheld per returnDirect policy)"
: "Tool execution failed: " + e.getMessage();
- events.add(GraphEventPublisher.toolComplete(toolName, safeError, false));
+ events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, safeError, false));
return new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, safeError);
}
}
@@ -559,7 +559,7 @@ public class ToolExecutionExecutor {
if (streamTracker != null) {
streamTracker.updateRunningTool(pc.conversationId, toolName);
streamTracker.broadcastObject(pc.conversationId, GraphEventPublisher.EVENT_TOOL_START,
- GraphEventPublisher.toolStart(toolName, pc.arguments).data());
+ GraphEventPublisher.toolStart(pc.toolCall.id(), toolName, pc.arguments).data());
}
log.info("[ToolExecutor] Executing tool: {} with args: {}",
toolName, pc.arguments != null && pc.arguments.length() > 200
@@ -611,10 +611,10 @@ public class ToolExecutionExecutor {
result = truncateToolResult(result, MAX_TOOL_RESULT_CHARS);
log.info("[ToolExecutor] Tool {} returned {} chars{}", toolName, rawLen,
result != null && result.length() < rawLen ? " (now " + result.length() + " after spill/truncate)" : "");
- events.add(GraphEventPublisher.toolComplete(toolName, result, true));
+ events.add(GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, result, true));
if (streamTracker != null) {
streamTracker.broadcastObject(pc.conversationId, GraphEventPublisher.EVENT_TOOL_COMPLETE,
- GraphEventPublisher.toolComplete(toolName, result, true).data());
+ GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, result, true).data());
streamTracker.updateRunningTool(pc.conversationId, null);
}
return new ToolResponseMessage.ToolResponse(
@@ -629,10 +629,10 @@ public class ToolExecutionExecutor {
String reportedError = isReturnDirect(pc.callback)
? "Tool execution failed (details withheld per returnDirect policy)"
: normalizeToolExecutionError(e);
- events.add(GraphEventPublisher.toolComplete(toolName, reportedError, false));
+ events.add(GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, reportedError, false));
if (streamTracker != null) {
streamTracker.broadcastObject(pc.conversationId, GraphEventPublisher.EVENT_TOOL_COMPLETE,
- GraphEventPublisher.toolComplete(toolName, reportedError, false).data());
+ GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, reportedError, false).data());
streamTracker.updateRunningTool(pc.conversationId, null);
}
return new ToolResponseMessage.ToolResponse(
@@ -653,7 +653,7 @@ public class ToolExecutionExecutor {
if (evaluation.shouldBlock()) {
log.warn("[ToolExecutor] Tool call BLOCKED: tool={}, summary={}", toolName, evaluation.summary());
- events.add(GraphEventPublisher.toolComplete(toolName, evaluation.summary(), false));
+ events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, evaluation.summary(), false));
return GuardDecision.blocked(
"[安全拦截] " + evaluation.summary() + "。请使用更安全的替代方案。");
}
@@ -672,7 +672,7 @@ public class ToolExecutionExecutor {
if (guardResult.isBlocked()) {
log.warn("[ToolExecutor] Tool call BLOCKED by ToolGuard: tool={}, reason={}", toolName, guardResult.reason());
- events.add(GraphEventPublisher.toolComplete(toolName, guardResult.reason(), false));
+ events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, guardResult.reason(), false));
return GuardDecision.blocked(
"[安全拦截] " + guardResult.reason() + "。请使用更安全的替代方案。");
}
diff --git a/mateclaw-server/src/main/java/vip/mate/approval/ApprovalController.java b/mateclaw-server/src/main/java/vip/mate/approval/ApprovalController.java
index 702eb67c..2d08dcc8 100644
--- a/mateclaw-server/src/main/java/vip/mate/approval/ApprovalController.java
+++ b/mateclaw-server/src/main/java/vip/mate/approval/ApprovalController.java
@@ -72,6 +72,14 @@ public class ApprovalController {
log.info("[Approval] User {} {} pending {} for conversation {}",
username, decision, request.getPendingId(), conversationId);
+ // Persist the resolved status onto the assistant message metadata so a
+ // subsequent page refresh doesn't hydrate a ghost approval banner from
+ // the stale "pending_approval" status frozen at message-save time.
+ conversationService.markPendingApprovalsResolved(
+ conversationId,
+ java.util.Set.of(request.getPendingId()),
+ "approved".equalsIgnoreCase(decision) ? "approved" : "denied");
+
// Web 端的 replay 由前端发送 /approve 消息到 POST /stream 触发(ChatController 拦截)
// 此端点只更新审批状态,保留给 IM 渠道(DingTalk/Feishu 等通过 ChannelMessageRouter 调用)
diff --git a/mateclaw-server/src/main/java/vip/mate/approval/ApprovalService.java b/mateclaw-server/src/main/java/vip/mate/approval/ApprovalService.java
index 8a05c5b8..0a46420c 100644
--- a/mateclaw-server/src/main/java/vip/mate/approval/ApprovalService.java
+++ b/mateclaw-server/src/main/java/vip/mate/approval/ApprovalService.java
@@ -120,6 +120,37 @@ public class ApprovalService {
log.info("[Approval] Resolved: id={}, decision={}, by={}", pendingId, decision, userId);
}
+ /**
+ * Bulk-deny every pending approval still in {@code pending} status for this
+ * conversation. Used by the Stop endpoint to clear orphaned approvals so
+ * subsequent UI refreshes don't keep popping the "approve write_file?"
+ * banner forever, and so a `findPendingByConversation` lookup right after
+ * Stop returns null.
+ *
+ * Returns the list of {@link PendingApproval} records that were marked
+ * denied — callers typically use this list to update the corresponding
+ * {@code mate_message.metadata.pendingApproval.status} entries in DB
+ * (otherwise a page refresh re-hydrates ghost approvals from message
+ * metadata even after the in-memory map is cleared).
+ */
+ public List denyAllByConversation(String conversationId, String userId) {
+ Instant now = Instant.now();
+ List resolved = new ArrayList<>();
+ for (PendingApproval pending : pendingMap.values()) {
+ if (!conversationId.equals(pending.getConversationId())) continue;
+ if (!"pending".equals(pending.getStatus())) continue;
+ pending.setStatus("denied");
+ pending.setResolvedAt(now);
+ pending.setResolvedBy(userId);
+ resolved.add(pending);
+ }
+ if (!resolved.isEmpty()) {
+ log.info("[Approval] Bulk-denied {} pending approvals for conversation {}",
+ resolved.size(), conversationId);
+ }
+ return resolved;
+ }
+
// ==================== 查询 ====================
/**
diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java
index 3742fd18..e934ce85 100644
--- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java
+++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java
@@ -194,6 +194,11 @@ public class ChatController {
if (isDenyCommand) {
approvalService.resolve(pending.getPendingId(), username, "denied");
conversationService.removeApprovalPlaceholders(conversationId);
+ // Sync the persisted message metadata so a subsequent page refresh
+ // doesn't re-hydrate a "pending_approval" ghost from message metadata
+ // that the in-memory map already moved past.
+ conversationService.markPendingApprovalsResolved(conversationId,
+ java.util.Set.of(pending.getPendingId()), "denied");
log.info("[Approval-Stream] User {} denied pending {} for conversation {}",
username, pending.getPendingId(), conversationId);
}
@@ -212,6 +217,11 @@ public class ChatController {
}
// 清理 DB 中残留的审批占位消息(对齐 IM 渠道 replayApprovedToolCall)
conversationService.removeApprovalPlaceholders(conversationId);
+ // Same metadata sync as the deny branch — without this, refresh
+ // after approval still shows the spinner-state approval card
+ // because the persisted message says status='pending_approval'.
+ conversationService.markPendingApprovalsResolved(conversationId,
+ java.util.Set.of(consumed.getPendingId()), "approved");
log.info("[Approval-Stream] User {} approved pending {} for conversation {}",
username, consumed.getPendingId(), conversationId);
}
@@ -762,18 +772,50 @@ public class ChatController {
/**
* 停止指定会话的流式生成。
* 取消 Flux 订阅(底层 HTTP 连接也会随之关闭),已生成的部分内容以 stopped 状态入库。
+ *
+ * Stop 同时清理所有未 resolve 的 pending approval:当 LLM 在一个 turn 里连发了
+ * 多个需要审批的工具调用、用户在中间 Stop 时,这些 pending 会一直留在 in-memory
+ * pendingMap 里。下次刷新页面时 frontend 的 hydrate 链路(`getPendingApprovals` API
+ * + 消息 metadata 里的 `pendingApproval` 字段)会反复弹出"允许 xxx 执行?"banner。
+ * Stop 端点现在 deny 所有 pending、同步 update 受影响 message 的 metadata,并广播
+ * tool_approval_resolved 让前端实时清理 UI。
*/
@Operation(summary = "停止流式生成")
@PostMapping("/{conversationId}/stop")
- public R