fix(chat): gracefully terminate active tool runs

Stopping a conversation previously disposed the outer reactive stream without reliably cancelling synchronous tool callbacks running on worker threads. Shell commands, skill scripts, and Playwright sessions could therefore outlive the visible chat turn.

Propagate cancellation through run-scoped hooks, interrupt active tool execution and parallel batches, terminate subprocess trees, close per-conversation browser sessions, and wait briefly for final stream persistence before acknowledging Stop.

Expose an explicit interrupting state in the chat input to block duplicate stop clicks and show progress, with regression coverage for cancelling an in-flight synchronous tool callback.
This commit is contained in:
mateaix 2026-08-16 16:26:10 +08:00
parent 6e8e7db31e
commit 81c6f4aece
9 changed files with 352 additions and 16 deletions

View File

@ -734,7 +734,12 @@ public class ToolExecutionExecutor {
return new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, msg); return new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, msg);
} }
Thread executionThread = Thread.currentThread();
Runnable removeCancellationHook = streamTracker != null
? streamTracker.registerCancellationHook(conversationId, executionThread::interrupt)
: () -> { };
try { try {
throwIfStopRequested(conversationId);
log.info("[ToolExecutor] Executing pre-approved tool: {}", toolName); log.info("[ToolExecutor] Executing pre-approved tool: {}", toolName);
// RFC-063r §2.5: forward ToolContext so the pre-approved tool can // RFC-063r §2.5: forward ToolContext so the pre-approved tool can
// still observe the originating ChatOrigin (channel/workspace). // still observe the originating ChatOrigin (channel/workspace).
@ -745,6 +750,7 @@ public class ToolExecutionExecutor {
.withConversationId(conversationId) .withConversationId(conversationId)
.withWorkspace(null, workspaceBasePath); .withWorkspace(null, workspaceBasePath);
String result = callback.call(callArguments, toolContextWithScopedCatalog(replayOrigin)); String result = callback.call(callArguments, toolContextWithScopedCatalog(replayOrigin));
throwIfStopRequested(conversationId);
int rawLen = result != null ? result.length() : 0; int rawLen = result != null ? result.length() : 0;
// RFC-052: pre-approved tool may itself be returnDirect in that // RFC-052: pre-approved tool may itself be returnDirect in that
@ -779,6 +785,8 @@ public class ToolExecutionExecutor {
// leaving the broadcast tool-result panel unchanged. // leaving the broadcast tool-result panel unchanged.
return new ToolResponseMessage.ToolResponse( return new ToolResponseMessage.ToolResponse(
toolCall.id(), toolName, withProductCardDirective(toolName, result != null ? result : "")); toolCall.id(), toolName, withProductCardDirective(toolName, result != null ? result : ""));
} catch (CancellationException e) {
throw e;
} catch (Exception e) { } catch (Exception e) {
log.error("[ToolExecutor] Pre-approved tool {} failed: {}", toolName, e.getMessage()); log.error("[ToolExecutor] Pre-approved tool {} failed: {}", toolName, e.getMessage());
String safeError = isReturnDirect(callback) String safeError = isReturnDirect(callback)
@ -786,6 +794,11 @@ public class ToolExecutionExecutor {
: "Tool execution failed: " + e.getMessage(); : "Tool execution failed: " + e.getMessage();
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, safeError, false)); events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, safeError, false));
return new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, safeError); return new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, safeError);
} finally {
removeCancellationHook.run();
if (streamTracker != null && streamTracker.isStopRequested(conversationId)) {
Thread.interrupted();
}
} }
} }
@ -826,6 +839,7 @@ public class ToolExecutionExecutor {
List<List<PreparedToolCall>> batches = buildExecutionBatches(preparedCalls); List<List<PreparedToolCall>> batches = buildExecutionBatches(preparedCalls);
for (List<PreparedToolCall> batch : batches) { for (List<PreparedToolCall> batch : batches) {
throwIfStopRequested(preparedCalls.isEmpty() ? null : preparedCalls.get(0).conversationId);
if (batch.size() == 1) { if (batch.size() == 1) {
// 单个工具safe unsafe直接执行 // 单个工具safe unsafe直接执行
PreparedToolCall pc = batch.get(0); PreparedToolCall pc = batch.get(0);
@ -893,13 +907,26 @@ public class ToolExecutionExecutor {
// 等待所有并行工具完成按原始顺序填入结果 // 等待所有并行工具完成按原始顺序填入结果
for (var entry : futures.entrySet()) { for (var entry : futures.entrySet()) {
try { try {
String conversationId = batch.isEmpty() ? null : batch.get(0).conversationId;
if (streamTracker != null && streamTracker.isStopRequested(conversationId)) {
futures.values().forEach(future -> future.cancel(true));
throw new CancellationException("Stream stopped by user during tool execution");
}
// 按工具名查找配置的超时时间 // 按工具名查找配置的超时时间
PreparedToolCall matchedPc = batch.stream() PreparedToolCall matchedPc = batch.stream()
.filter(p -> p.resultIndex == entry.getKey()).findFirst().orElse(null); .filter(p -> p.resultIndex == entry.getKey()).findFirst().orElse(null);
long timeoutMs = getToolTimeoutMs(matchedPc != null ? matchedPc.toolCall.name() : null); long timeoutMs = getToolTimeoutMs(matchedPc != null ? matchedPc.toolCall.name() : null);
ToolResponseMessage.ToolResponse response = entry.getValue().get(timeoutMs, TimeUnit.MILLISECONDS); ToolResponseMessage.ToolResponse response = entry.getValue().get(timeoutMs, TimeUnit.MILLISECONDS);
allResponses.set(entry.getKey(), response); allResponses.set(entry.getKey(), response);
} catch (CancellationException e) {
futures.values().forEach(future -> future.cancel(true));
throw e;
} catch (Exception e) { } catch (Exception e) {
String conversationId = batch.isEmpty() ? null : batch.get(0).conversationId;
if (streamTracker != null && streamTracker.isStopRequested(conversationId)) {
futures.values().forEach(future -> future.cancel(true));
throw new CancellationException("Stream stopped by user during tool execution");
}
// 超时或异常 填入错误响应 // 超时或异常 填入错误响应
PreparedToolCall pc = batch.stream() PreparedToolCall pc = batch.stream()
.filter(p -> p.resultIndex == entry.getKey()) .filter(p -> p.resultIndex == entry.getKey())
@ -920,7 +947,12 @@ public class ToolExecutionExecutor {
List<GraphEventPublisher.GraphEvent> events, List<GraphEventPublisher.GraphEvent> events,
List<DirectToolOutput> directOutputs) { List<DirectToolOutput> directOutputs) {
String toolName = pc.toolCall.name(); String toolName = pc.toolCall.name();
Thread executionThread = Thread.currentThread();
Runnable removeCancellationHook = streamTracker != null
? streamTracker.registerCancellationHook(pc.conversationId, executionThread::interrupt)
: () -> { };
try { try {
throwIfStopRequested(pc.conversationId);
if (streamTracker != null) { if (streamTracker != null) {
streamTracker.updateRunningTool(pc.conversationId, toolName); streamTracker.updateRunningTool(pc.conversationId, toolName);
streamTracker.broadcastObject(pc.conversationId, GraphEventPublisher.EVENT_TOOL_START, streamTracker.broadcastObject(pc.conversationId, GraphEventPublisher.EVENT_TOOL_START,
@ -956,6 +988,7 @@ public class ToolExecutionExecutor {
} }
result = pc.callback.call(pc.arguments, toolContext); result = pc.callback.call(pc.arguments, toolContext);
throwIfStopRequested(pc.conversationId);
} finally { } finally {
if (progressToken != null) { if (progressToken != null) {
progressContext.remove(progressToken); progressContext.remove(progressToken);
@ -1035,6 +1068,11 @@ public class ToolExecutionExecutor {
return new ToolResponseMessage.ToolResponse( return new ToolResponseMessage.ToolResponse(
pc.toolCall.id(), pc.responseName, pc.toolCall.id(), pc.responseName,
withProductCardDirective(toolName, result != null ? result : "")); withProductCardDirective(toolName, result != null ? result : ""));
} catch (CancellationException e) {
if (streamTracker != null) {
streamTracker.updateRunningTool(pc.conversationId, null);
}
throw e;
} catch (Exception e) { } catch (Exception e) {
log.error("[ToolExecutor] Tool {} execution failed: {}", toolName, e.getMessage(), e); log.error("[ToolExecutor] Tool {} execution failed: {}", toolName, e.getMessage(), e);
// RFC-052: for returnDirect tools, even the error message is // RFC-052: for returnDirect tools, even the error message is
@ -1053,6 +1091,20 @@ public class ToolExecutionExecutor {
} }
return new ToolResponseMessage.ToolResponse( return new ToolResponseMessage.ToolResponse(
pc.toolCall.id(), pc.responseName, reportedError); pc.toolCall.id(), pc.responseName, reportedError);
} finally {
removeCancellationHook.run();
// Virtual-thread workers are not reused, but single/unsafe calls
// can execute on a Reactor worker. Do not leak Stop's interrupt bit
// into unrelated work scheduled on that carrier.
if (streamTracker != null && streamTracker.isStopRequested(pc.conversationId)) {
Thread.interrupted();
}
}
}
private void throwIfStopRequested(String conversationId) {
if (streamTracker != null && streamTracker.isStopRequested(conversationId)) {
throw new CancellationException("Stream stopped by user during tool execution");
} }
} }

View File

@ -1027,6 +1027,10 @@ public class ChatController {
return R.fail(403, "无权操作该会话"); return R.fail(403, "无权操作该会话");
} }
boolean stopped = streamTracker.requestStop(conversationId); boolean stopped = streamTracker.requestStop(conversationId);
// Acknowledge only after the cancellation path had a chance to drain
// and persist its partial assistant message. Bound the wait so a
// genuinely non-cooperative third-party tool cannot pin the HTTP call.
boolean terminationConfirmed = !stopped || streamTracker.awaitTermination(conversationId, 2000L);
// Sweep ghost approvals workflow.denyAllByConversation owns DB + metadata + memory // Sweep ghost approvals workflow.denyAllByConversation owns DB + metadata + memory
// atomically; we only need to broadcast SSE events on the resulting outcomes. // atomically; we only need to broadcast SSE events on the resulting outcomes.
@ -1045,6 +1049,7 @@ public class ChatController {
conversationId, username, stopped, denied.size(), messagesRewritten); conversationId, username, stopped, denied.size(), messagesRewritten);
return R.ok(Map.of( return R.ok(Map.of(
"stopped", stopped, "stopped", stopped,
"terminationConfirmed", terminationConfirmed,
"ghostPendingsCleared", denied.size(), "ghostPendingsCleared", denied.size(),
"messagesRewritten", messagesRewritten "messagesRewritten", messagesRewritten
)); ));

View File

@ -160,6 +160,16 @@ public class ChatStreamTracker {
volatile Disposable disposable; volatile Disposable disposable;
/** 停止标志requestStop() 设为 true各图节点和 LLM 调用检查此标志以提前退出 */ /** 停止标志requestStop() 设为 true各图节点和 LLM 调用检查此标志以提前退出 */
final AtomicBoolean stopRequested = new AtomicBoolean(false); final AtomicBoolean stopRequested = new AtomicBoolean(false);
/**
* Cancellation hooks owned by work that has escaped the Reactor
* subscription (most notably synchronous ToolCallback invocations).
* Guarded by {@link #lock}; requestStop snapshots and invokes them
* outside the lock so a hook may safely deregister itself.
*/
final java.util.Set<Runnable> cancellationHooks = new java.util.HashSet<>();
/** Completed only after the run's finalization path has drained. */
final java.util.concurrent.CompletableFuture<Void> termination =
new java.util.concurrent.CompletableFuture<>();
/** /**
* 当前活跃的 Flux 数量原始流 + 审批 Replay 流共享同一个 RunState * 当前活跃的 Flux 数量原始流 + 审批 Replay 流共享同一个 RunState
* complete() 仅在计数归零时才真正移除 RunState防止 Replay 仍在运行时被原始流的完成误删 * complete() 仅在计数归零时才真正移除 RunState防止 Replay 仍在运行时被原始流的完成误删
@ -548,6 +558,46 @@ public class ChatStreamTracker {
} }
} }
/**
* Register cancellation for work performed outside the run's Reactor
* subscription. The returned handle is idempotent and must be closed when
* that work finishes. If Stop already won the race, the hook is invoked
* immediately instead of being registered.
*/
public Runnable registerCancellationHook(String conversationId, Runnable hook) {
if (conversationId == null || hook == null) {
return () -> { };
}
RunState state = runs.get(conversationId);
if (state == null) {
return () -> { };
}
boolean cancelImmediately;
synchronized (state.lock) {
cancelImmediately = !isCurrent(state) || state.done || state.stopRequested.get();
if (!cancelImmediately) {
state.cancellationHooks.add(hook);
}
}
if (cancelImmediately) {
invokeCancellationHook(conversationId, hook);
return () -> { };
}
return () -> {
synchronized (state.lock) {
state.cancellationHooks.remove(hook);
}
};
}
private void invokeCancellationHook(String conversationId, Runnable hook) {
try {
hook.run();
} catch (Exception e) {
log.warn("Cancellation hook failed for {}: {}", conversationId, e.getMessage());
}
}
/** /**
* Register an emergency-save callback for this run, invoked from {@link #onShutdown()} * Register an emergency-save callback for this run, invoked from {@link #onShutdown()}
* before the JVM tears down. The callback should snapshot the current accumulator * before the JVM tears down. The callback should snapshot the current accumulator
@ -581,12 +631,35 @@ public class ChatStreamTracker {
*/ */
public boolean requestStop(String conversationId) { public boolean requestStop(String conversationId) {
RunState state = runs.get(conversationId); RunState state = runs.get(conversationId);
if (state == null || state.done) { if (state == null) return false;
return false;
final boolean firstRequest;
final Disposable d;
final List<Runnable> hooks;
synchronized (state.lock) {
if (!isCurrent(state) || state.done) return false;
// Set the flag before taking the hook snapshot. A tool entering
// concurrently will then self-cancel in registerCancellationHook.
firstRequest = !state.stopRequested.getAndSet(true);
state.currentPhase = "interrupting";
state.runningToolName = null;
d = state.disposable;
hooks = new ArrayList<>(state.cancellationHooks);
state.cancellationHooks.clear();
}
// Let the UI render an explicit transition before cancellation closes
// the stream. This mirrors qwenpaw's cancel envelope instead of making
// the Stop button look unresponsive until final persistence finishes.
broadcastObject(conversationId, "phase", Map.of(
"phase", "interrupting",
"timestamp", System.currentTimeMillis()));
// Disposing the Flux alone cannot stop a synchronous callback already
// running on another thread. Cancel those escaped executions first.
for (Runnable hook : hooks) {
invokeCancellationHook(conversationId, hook);
} }
// 设置停止标志图节点和 LLM 调用会检查此标志以提前退出
boolean firstRequest = !state.stopRequested.getAndSet(true);
Disposable d = state.disposable;
if (d != null && !d.isDisposed()) { if (d != null && !d.isDisposed()) {
d.dispose(); d.dispose();
log.info("Stream stopped via requestStop: {}", conversationId); log.info("Stream stopped via requestStop: {}", conversationId);
@ -604,6 +677,23 @@ public class ChatStreamTracker {
return state != null && state.stopRequested.get(); return state != null && state.stopRequested.get();
} }
/**
* Wait briefly for cancellation finalization (partial-message persistence,
* done envelope, and lifecycle cleanup). This gives Stop callers the same
* acknowledgement semantics as qwenpaw's request_stop(), which awaits the
* cancelled task instead of merely sending a signal.
*/
public boolean awaitTermination(String conversationId, long timeoutMillis) {
RunState state = runs.get(conversationId);
if (state == null || state.done) return true;
try {
state.termination.get(Math.max(1L, timeoutMillis), TimeUnit.MILLISECONDS);
return true;
} catch (Exception e) {
return state.done;
}
}
/** /**
* Whether this conversation was force-recycled by an admin within the * Whether this conversation was force-recycled by an admin within the
* recycle marker's TTL ({@link #DONE_RETENTION_MS}). The SSE doOn* * recycle marker's TTL ({@link #DONE_RETENTION_MS}). The SSE doOn*
@ -1195,6 +1285,8 @@ public class ChatStreamTracker {
return false; return false;
} }
state.done = true; state.done = true;
state.cancellationHooks.clear();
state.termination.complete(null);
oldHeartbeat = state.heartbeatFuture; oldHeartbeat = state.heartbeatFuture;
state.heartbeatFuture = null; state.heartbeatFuture = null;
} }
@ -1238,6 +1330,8 @@ public class ChatStreamTracker {
// 最后一个 Flux在同一个锁内消费排队消息取队首 // 最后一个 Flux在同一个锁内消费排队消息取队首
consumed = state.messageQueue.poll(); consumed = state.messageQueue.poll();
state.done = true; state.done = true;
state.cancellationHooks.clear();
state.termination.complete(null);
oldHeartbeat = state.heartbeatFuture; oldHeartbeat = state.heartbeatFuture;
state.heartbeatFuture = null; state.heartbeatFuture = null;
} }
@ -1913,6 +2007,15 @@ public class ChatStreamTracker {
entry.getKey(), ex.getMessage()); entry.getKey(), ex.getMessage());
} }
try { try {
state.stopRequested.set(true);
List<Runnable> hooks;
synchronized (state.lock) {
hooks = new ArrayList<>(state.cancellationHooks);
state.cancellationHooks.clear();
}
for (Runnable hook : hooks) {
invokeCancellationHook(entry.getKey(), hook);
}
Disposable d = state.disposable; Disposable d = state.disposable;
if (d != null && !d.isDisposed()) { if (d != null && !d.isDisposed()) {
d.dispose(); d.dispose();
@ -1922,6 +2025,7 @@ public class ChatStreamTracker {
entry.getKey(), ex.getMessage()); entry.getKey(), ex.getMessage());
} }
} finally { } finally {
state.termination.complete(null);
mappingRemoved = runs.remove(entry.getKey(), state); mappingRemoved = runs.remove(entry.getKey(), state);
reclaimed++; reclaimed++;
if (mappingRemoved) { if (mappingRemoved) {
@ -1994,6 +2098,15 @@ public class ChatStreamTracker {
log.error("[ChatStreamTracker] Emergency save failed for {}: {}", log.error("[ChatStreamTracker] Emergency save failed for {}: {}",
cid, e.getMessage(), e); cid, e.getMessage(), e);
} }
state.stopRequested.set(true);
List<Runnable> hooks;
synchronized (state.lock) {
hooks = new ArrayList<>(state.cancellationHooks);
state.cancellationHooks.clear();
}
for (Runnable hook : hooks) {
invokeCancellationHook(cid, hook);
}
try { try {
Disposable d = state.disposable; Disposable d = state.disposable;
if (d != null && !d.isDisposed()) { if (d != null && !d.isDisposed()) {
@ -2003,6 +2116,7 @@ public class ChatStreamTracker {
log.warn("[ChatStreamTracker] Disposable.dispose failed for {}: {}", log.warn("[ChatStreamTracker] Disposable.dispose failed for {}: {}",
cid, e.getMessage()); cid, e.getMessage());
} }
state.termination.complete(null);
} }
} }
@ -2160,9 +2274,18 @@ public class ChatStreamTracker {
} }
} }
try { try {
state.stopRequested.set(true); final Disposable d;
state.interruptType = InterruptType.USER_STOP; final List<Runnable> hooks;
Disposable d = state.disposable; synchronized (state.lock) {
state.stopRequested.set(true);
state.interruptType = InterruptType.USER_STOP;
d = state.disposable;
hooks = new ArrayList<>(state.cancellationHooks);
state.cancellationHooks.clear();
}
for (Runnable hook : hooks) {
invokeCancellationHook(conversationId, hook);
}
if (d != null && !d.isDisposed()) { if (d != null && !d.isDisposed()) {
d.dispose(); d.dispose();
} }
@ -2171,6 +2294,7 @@ public class ChatStreamTracker {
} }
try { try {
state.done = true; state.done = true;
state.termination.complete(null);
stopHeartbeat(conversationId); stopHeartbeat(conversationId);
} catch (Exception e) { } catch (Exception e) {
log.warn("forceRecycle: heartbeat stop failed for {}: {}", conversationId, e.getMessage()); log.warn("forceRecycle: heartbeat stop failed for {}: {}", conversationId, e.getMessage());

View File

@ -168,6 +168,7 @@ public class SkillScriptExecutionService {
Path stdoutFile = null; Path stdoutFile = null;
Path stderrFile = null; Path stderrFile = null;
Process process = null;
try { try {
// 构建命令结构化参数避免 shell 注入 // 构建命令结构化参数避免 shell 注入
@ -242,7 +243,7 @@ public class SkillScriptExecutionService {
} }
injectPipMirrorEnv(pb); injectPipMirrorEnv(pb);
Process process = pb.start(); process = pb.start();
boolean finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS); boolean finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS);
if (!finished) { if (!finished) {
@ -259,6 +260,16 @@ public class SkillScriptExecutionService {
String stderr = readFileTruncated(stderrFile, MAX_OUTPUT_BYTES); String stderr = readFileTruncated(stderrFile, MAX_OUTPUT_BYTES);
return new ScriptResult(exitCode, stdout, stderr); return new ScriptResult(exitCode, stdout, stderr);
} catch (InterruptedException e) {
// Stop must terminate the OS process as well as the Java wait.
// Without this, cancelling the outer chat stream leaves skill
// scripts running until their normal timeout.
if (process != null && process.isAlive()) {
killProcess(process);
}
Thread.currentThread().interrupt();
log.info("Skill script interrupted by conversation cancellation: {}", scriptPath);
return ScriptResult.error(-1, "Execution cancelled by user");
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to execute script {}: {}", scriptPath, e.getMessage()); log.error("Failed to execute script {}: {}", scriptPath, e.getMessage());
return ScriptResult.error(-1, "Execution error: " + e.getMessage()); return ScriptResult.error(-1, "Execution error: " + e.getMessage());

View File

@ -248,6 +248,12 @@ public class BrowserUseTool {
String conversationId = ToolExecutionContext.conversationId(ctx); String conversationId = ToolExecutionContext.conversationId(ctx);
String sessionKey = (conversationId != null && !conversationId.isBlank()) String sessionKey = (conversationId != null && !conversationId.isBlank())
? conversationId : "default"; ? conversationId : "default";
// Closing the per-conversation session aborts Playwright's native
// wait/navigation even when a Java thread interrupt alone is not
// observed by the driver transport.
Runnable removeCancellationHook = streamTracker != null && conversationId != null
? streamTracker.registerCancellationHook(conversationId, () -> doStop(sessionKey))
: () -> { };
log.info("[BrowserUse] action={}, session={}, url={}, selector={}, headed={}, cdpPort={}", log.info("[BrowserUse] action={}, session={}, url={}, selector={}, headed={}, cdpPort={}",
action, sessionKey, url, selector, headed, cdpPort); action, sessionKey, url, selector, headed, cdpPort);
@ -283,6 +289,8 @@ public class BrowserUseTool {
} finally { } finally {
currentToolContext.remove(); currentToolContext.remove();
} }
} finally {
removeCancellationHook.run();
} }
} }

View File

@ -90,6 +90,7 @@ public class ShellExecuteTool {
Path stdoutFile = null; Path stdoutFile = null;
Path stderrFile = null; Path stderrFile = null;
Process process = null;
try { try {
// 处理命令中的嵌入换行符LLM 生成的 JSON 解码后可能包含真实换行 // 处理命令中的嵌入换行符LLM 生成的 JSON 解码后可能包含真实换行
@ -113,7 +114,7 @@ public class ShellExecuteTool {
pb.redirectError(stderrFile.toFile()); pb.redirectError(stderrFile.toFile());
long runStart = System.currentTimeMillis(); long runStart = System.currentTimeMillis();
Process process = pb.start(); process = pb.start();
boolean completed = process.waitFor(timeout, TimeUnit.SECONDS); boolean completed = process.waitFor(timeout, TimeUnit.SECONDS);
@ -146,6 +147,20 @@ public class ShellExecuteTool {
} }
} }
} catch (InterruptedException e) {
// A conversation Stop interrupts the active tool thread. Kill the
// subprocess tree before returning control; otherwise the Flux is
// gone but the shell command keeps running in the background.
if (process != null && process.isAlive()) {
killProcessTree(process);
}
Thread.currentThread().interrupt();
log.info("[ShellExecute] Command interrupted by cancellation");
result.set("exitCode", -1);
result.set("stdout", "");
result.set("stderr", "Command cancelled by user");
result.set("timedOut", false);
result.set("cancelled", true);
} catch (Exception e) { } catch (Exception e) {
log.error("[ShellExecute] Command execution failed: {}", e.getMessage(), e); log.error("[ShellExecute] Command execution failed: {}", e.getMessage(), e);
result.set("exitCode", -1); result.set("exitCode", -1);

View File

@ -0,0 +1,84 @@
package vip.mate.agent.graph.executor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.ai.tool.metadata.ToolMetadata;
import vip.mate.agent.AgentToolSet;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.tool.guard.ToolGuard;
import vip.mate.tool.guard.ToolGuardResult;
import java.util.List;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ToolExecutionExecutorCancellationTest {
@Test
@DisplayName("Stop interrupts an in-flight synchronous tool instead of only disposing the outer stream")
void stopInterruptsActiveToolThread() throws Exception {
String conversationId = "cancel-tool-conversation";
ChatStreamTracker tracker = new ChatStreamTracker(new ObjectMapper());
tracker.register(conversationId);
CountDownLatch entered = new CountDownLatch(1);
CountDownLatch interrupted = new CountDownLatch(1);
ToolCallback blockingTool = blockingTool(entered, interrupted);
AgentToolSet toolSet = AgentToolSet.fromCallbacks(List.of(), List.of(blockingTool));
ToolGuard alwaysAllow = (name, arguments) -> ToolGuardResult.allow();
ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, alwaysAllow, null, tracker);
AssistantMessage.ToolCall call = new AssistantMessage.ToolCall(
"call-1", "function", "blocking_tool", "{}");
CompletableFuture<?> execution = CompletableFuture.runAsync(() ->
executor.execute(List.of(call), conversationId, "agent-1", false));
assertTrue(entered.await(2, TimeUnit.SECONDS), "tool callback should have started");
assertTrue(tracker.requestStop(conversationId), "active run should accept Stop");
assertTrue(interrupted.await(2, TimeUnit.SECONDS), "Stop must interrupt the tool thread");
ExecutionException error = assertThrows(ExecutionException.class,
() -> execution.get(2, TimeUnit.SECONDS));
assertInstanceOf(CancellationException.class, error.getCause());
}
private static ToolCallback blockingTool(CountDownLatch entered, CountDownLatch interrupted) {
ToolDefinition definition = ToolDefinition.builder()
.name("blocking_tool")
.description("blocks until interrupted")
.inputSchema("{\"type\":\"object\",\"properties\":{}}")
.build();
return new ToolCallback() {
@Override public ToolDefinition getToolDefinition() { return definition; }
@Override public ToolMetadata getToolMetadata() {
return ToolMetadata.builder().returnDirect(false).build();
}
@Override public String call(String arguments) { return runBlocking(); }
@Override public String call(String arguments, ToolContext toolContext) { return runBlocking(); }
private String runBlocking() {
entered.countDown();
try {
Thread.sleep(TimeUnit.MINUTES.toMillis(1));
return "unexpected completion";
} catch (InterruptedException e) {
interrupted.countDown();
Thread.currentThread().interrupt();
return "cancelled";
}
}
};
}
}

View File

@ -204,11 +204,13 @@
type="button" type="button"
class="action-btn send-btn" class="action-btn send-btn"
:class="sendBtnClass" :class="sendBtnClass"
:disabled="!canSend && !loading" :disabled="props.streamPhase === 'interrupting' || (!canSend && !loading)"
:title="props.streamPhase === 'interrupting' ? t('chat.streamInterrupting') : undefined"
@click="handleSubmit" @click="handleSubmit"
> >
<!-- 有输入时始终显示发送图标运行中发送 = interrupt --> <!-- 有输入时始终显示发送图标运行中发送 = interrupt -->
<el-icon v-if="canSend"><Promotion /></el-icon> <el-icon v-if="props.streamPhase === 'interrupting'" class="stop-spinner"><Loading /></el-icon>
<el-icon v-else-if="canSend"><Promotion /></el-icon>
<!-- 运行中无输入停止图标 --> <!-- 运行中无输入停止图标 -->
<el-icon v-else-if="loading"><CloseBold /></el-icon> <el-icon v-else-if="loading"><CloseBold /></el-icon>
<!-- 空闲无输入 --> <!-- 空闲无输入 -->
@ -241,7 +243,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, nextTick, watch } from 'vue' import { ref, computed, nextTick, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { ArrowDown, CloseBold, MagicStick, Microphone, Paperclip, Promotion, Select, Timer, WarningFilled } from '@element-plus/icons-vue' import { ArrowDown, CloseBold, Loading, MagicStick, Microphone, Paperclip, Promotion, Select, Timer, WarningFilled } from '@element-plus/icons-vue'
import { useToolLabel } from '@/composables/useToolLabel' import { useToolLabel } from '@/composables/useToolLabel'
import SkillSlashMenu from '@/components/chat/SkillSlashMenu.vue' import SkillSlashMenu from '@/components/chat/SkillSlashMenu.vue'
import type { ChatAttachment, PendingApprovalMeta, StreamPhase, QueuedMessage, Skill } from '@/types' import type { ChatAttachment, PendingApprovalMeta, StreamPhase, QueuedMessage, Skill } from '@/types'
@ -489,6 +491,7 @@ const canSend = computed(() => {
// //
const inputPlaceholder = computed(() => { const inputPlaceholder = computed(() => {
if (props.streamPhase === 'interrupting') return t('chat.streamInterrupting')
if (props.loading) { if (props.loading) {
if (props.queuedMessage) return t('chat.queuedReplace') if (props.queuedMessage) return t('chat.queuedReplace')
return props.placeholder return props.placeholder
@ -498,6 +501,7 @@ const inputPlaceholder = computed(() => {
// //
const handleSubmit = () => { const handleSubmit = () => {
if (props.streamPhase === 'interrupting') return
// //
if (props.queuedMessage && props.queuedMessage.status === 'queued') { if (props.queuedMessage && props.queuedMessage.status === 'queued') {
// //
@ -530,6 +534,7 @@ const handleSubmit = () => {
// //
const sendBtnClass = computed(() => ({ const sendBtnClass = computed(() => ({
'is-loading': props.loading && !canSend.value, 'is-loading': props.loading && !canSend.value,
'is-stopping': props.streamPhase === 'interrupting',
'is-empty': !canSend.value && !props.loading, 'is-empty': !canSend.value && !props.loading,
'is-interrupt': props.loading && canSend.value, 'is-interrupt': props.loading && canSend.value,
})) }))
@ -857,6 +862,21 @@ defineExpose({
background: var(--mc-danger-hover, #dc2626); background: var(--mc-danger-hover, #dc2626);
} }
.send-btn.is-stopping,
.send-btn.is-stopping:disabled {
background: var(--mc-text-tertiary, #94a3b8);
opacity: 1;
cursor: wait;
}
.stop-spinner {
animation: stop-spin 0.8s linear infinite;
}
@keyframes stop-spin {
to { transform: rotate(360deg); }
}
.send-btn.is-empty:not(.is-loading) { .send-btn.is-empty:not(.is-loading) {
opacity: 0.4; opacity: 0.4;
cursor: not-allowed; cursor: not-allowed;

View File

@ -2161,8 +2161,10 @@ export function useChat(options: UseChatOptions): UseChatReturn {
// Cancel queued message first // Cancel queued message first
messageQueue.clear() messageQueue.clear()
// Mark as stopped immediately so the UI gives instant feedback // Stop is a transition, not an instantaneous terminal state. Keep the
streamPhase.value = 'stopped' // assistant message generating until the server's done envelope arrives,
// but expose an explicit phase so the button cannot be clicked repeatedly.
streamPhase.value = 'interrupting'
phaseInfo.value = null phaseInfo.value = null
compactStatus.value = null compactStatus.value = null
@ -2195,12 +2197,27 @@ export function useChat(options: UseChatOptions): UseChatReturn {
unsubscribeError() unsubscribeError()
}) })
// Send the backend stop request (fire-and-forget, does not block resetForNewConversation) // SSE remains authoritative for final content/status. An HTTP failure
// accelerates local cleanup instead of leaving the UI in "interrupting".
if (convId) { if (convId) {
fetchWithAuth(`${baseUrl}/api/v1/chat/${convId}/stop`, { fetchWithAuth(`${baseUrl}/api/v1/chat/${convId}/stop`, {
method: 'POST', method: 'POST',
}).then(response => {
if (!response.ok) throw new Error(`Stop request failed (${response.status})`)
}).catch(e => { }).catch(e => {
console.warn('[useChat] Stop API failed:', e) console.warn('[useChat] Stop API failed:', e)
if (stopFallbackTimer) {
clearTimeout(stopFallbackTimer)
stopFallbackTimer = setTimeout(() => {
stopFallbackTimer = null
if (streamConversationId === convId || !streamConversationId) stream.disconnect()
if (currentAssistantId.value === assistantId && assistantId) {
setMessageStatus(assistantId, 'stopped')
currentAssistantId.value = null
}
onStreamEnd?.({ conversationId: convId, reason: 'stopped' })
}, 250)
}
}) })
} }
} }