feat(webchat): stop an in-flight session stream (POST /sessions/stop)

Until now webchat had no way to actually interrupt a running stream —
ChatController's /api/v1/chat/{id}/stop was technically permitAll'd but
silently no-op'd on webchat streams because WebChatController.chatStream
dropped the subscribe() return value, so ChatStreamTracker.requestStop
had no Disposable to dispose. Visitors could only "stop" client-side by
closing the SSE connection; the server-side LLM call kept running,
burning tokens and firing any side-effecting tools to completion.

Two changes (issue #353):

1. WebChatController.chatStream: keep the Disposable and register it
   with streamTracker.setDisposable, mirroring ChatController#chatStream
   line 495. Now requestStop actually disposes the Flux.

2. New endpoint POST /api/v1/channels/webchat/sessions/stop:
   - Auth mirrors the other session-management endpoints: X-MC-Key +
     X-MC-Visitor-Token + ownsConversation (404 on unknown sessionId,
     so callers can't probe the namespace).
   - Returns {stopped: true|false}; false means no active stream
     (idempotent, not an error).
   - No approval sweep — webchat has no MateClaw username and exposes
     no approval UI today; defer until that surfaces.

WebChatStopStreamTest (@SpringBootTest, H2, V147) — 5 cases:
- stopActiveStream registers a real Flux.never() Disposable on the
  tracker and asserts both stopped=true AND disposable.isDisposed(),
  proving the chatStream wiring change is what makes the endpoint work.
- noActiveStreamReturnsFalse — idempotent path.
- bad token / bad API key → 401.
- unknown sessionId → 404.
This commit is contained in:
倪程伟 2026-06-17 23:20:10 +08:00 committed by matevip
parent d84be668fa
commit 23c1d49241
2 changed files with 185 additions and 1 deletions

View File

@ -203,7 +203,7 @@ public class WebChatController {
.withSender(null, "api", null);
String webchatOwnerKey = memoryOwnerResolver.resolve(webchatOrigin);
agentService.chatStructuredStream(resolvedAgentId, message, conversationId, visitorId, null, webchatOrigin)
reactor.core.Disposable disposable = agentService.chatStructuredStream(resolvedAgentId, message, conversationId, visitorId, null, webchatOrigin)
.doOnNext(delta -> {
if (delta.isEvent() && "_usage_final".equals(delta.eventType())) {
Map<String, Object> data = delta.eventData();
@ -251,6 +251,12 @@ public class WebChatController {
streamTracker.complete(conversationId);
})
.subscribe();
// Bind the subscription's Disposable so requestStop() (invoked by
// POST /sessions/stop) can actually dispose the Flux and interrupt
// the LLM stream. Without this, stopRequested is set but the underlying
// HTTP call keeps running token burn + side-effect tools still fire.
// Mirrors ChatController#chatStream line 495.
streamTracker.setDisposable(conversationId, disposable);
} catch (Exception e) {
log.error("[WebChat] Error: {}", e.getMessage(), e);
@ -637,6 +643,51 @@ public class WebChatController {
return R.ok();
}
/**
* 停止访客某线程正在进行中的 SSE
* <p>
* 鉴权同其他会话管理端点(API Key + visitorToken + 会话归属)内部调
* {@link ChatStreamTracker#requestStop(String)} chatStream 注册时绑定的
* Disposable 实际中断 Flux;返回 {@code stopped=false} 表示当前没有活跃流
* (幂等,不报错)
* <p>
* 不做 approval sweep:webchat 渠道目前不暴露 approval UI,且无 MateClaw
* username 可传给 {@code denyAllByConversation}若未来 webchat 接入审批流,
* 再单独评估是否补这层
*/
@Operation(summary = "停止访客会话线程的进行中流")
@PostMapping("/sessions/stop")
public R<Map<String, Object>> stopSession(
@RequestHeader("X-MC-Key") String apiKey,
@RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken,
@RequestParam String visitorId,
@RequestParam(required = false) String sessionId) {
ChannelEntity channel = resolveChannel(apiKey);
if (channel == null) {
return R.fail(401, "Invalid API Key");
}
if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) {
return R.fail(401, "Invalid or missing visitor token");
}
String sid;
try {
sid = normalizeSessionId(sessionId);
} catch (IllegalArgumentException ex) {
return R.fail(400, ex.getMessage());
}
String conversationId = deriveConversationId(apiKey, visitorId, sid);
// ownsConversation is the existence + ownership guard: an unknown sessionId
// maps to a conversationId that either doesn't exist or belongs to someone
// else both return 404 so the caller can't probe the namespace.
if (!ownsConversation(conversationId, visitorId)) {
return R.fail(404, "Session not found");
}
boolean stopped = streamTracker.requestStop(conversationId);
log.info("[WebChat] Stop requested: conversationId={}, visitor={}, stopped={}",
conversationId, visitorId, stopped);
return R.ok(Map.of("stopped", stopped));
}
/**
* 上传文件入站访客先上传拿到 fileId再在 /stream attachmentIds 中引用
* <p>鉴权同会话接口API Key + visitor tokenconversationId 服务端派生

View File

@ -0,0 +1,133 @@
package vip.mate.channel.webchat;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.TestPropertySource;
import reactor.core.Disposable;
import reactor.core.publisher.Flux;
import vip.mate.MateClawApplication;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.channel.webchat.WebChatController.WebChatCreateSessionRequest;
import vip.mate.common.result.R;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* End-to-end verification of {@code POST /api/v1/channels/webchat/sessions/stop}
* against a booted context + real H2 (migrations incl. V147).
* <p>
* The non-trivial case is {@link #stopsActiveStream()}: a real Reactor
* {@code Disposable} is registered on the tracker (mirroring what
* {@code WebChatController.chatStream} now does after subscribe), and the test
* asserts that {@code stopSession} both returns {@code stopped=true} AND
* actually disposes the underlying subscription proving the chatStream
* wiring change is what makes the new endpoint functional rather than a no-op.
*/
@SpringBootTest(
classes = MateClawApplication.class,
webEnvironment = SpringBootTest.WebEnvironment.NONE
)
@TestPropertySource(properties = {
"spring.datasource.url=jdbc:h2:mem:webchat_stop_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1",
"spring.ai.dashscope.api-key=test-key",
"spring.main.web-application-type=none",
"mateclaw.jwt.secret=webchat-it-secret-0123456789"
})
class WebChatStopStreamTest {
private static final String SECRET = "webchat-it-secret-0123456789";
private static final String API_KEY = "testkey1abcdefgh"; // key8 = "testkey1"
private static final long CHANNEL_ID = 9_147_201L;
private static final long AGENT_ID = 9_147_2011L;
@Autowired private WebChatController controller;
@Autowired private ChatStreamTracker streamTracker;
@Autowired private JdbcTemplate jdbc;
@BeforeEach
void setUp() {
jdbc.update("DELETE FROM mate_channel WHERE id = ?", CHANNEL_ID);
jdbc.update("DELETE FROM mate_agent WHERE id = ?", AGENT_ID);
jdbc.update(
"MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " +
"workspace_id, create_time, update_time, deleted) " +
"KEY(id) VALUES (?, 'wc-stop-agent', 'react', '', 10, TRUE, 1, " +
"CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)",
AGENT_ID);
jdbc.update("INSERT INTO mate_channel (id, name, channel_type, agent_id, config_json, enabled, " +
"workspace_id, create_time, update_time, deleted) " +
"VALUES (?, 'wc', 'webchat', ?, ?, TRUE, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)",
CHANNEL_ID, AGENT_ID, "{\"api_key\":\"" + API_KEY + "\"}");
}
private WebChatCreateSessionRequest req(String visitorId, String sessionId) {
WebChatCreateSessionRequest r = new WebChatCreateSessionRequest();
r.setVisitorId(visitorId);
r.setSessionId(sessionId);
return r;
}
private String tokenFor(String visitorId) {
return WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, visitorId);
}
@Test
@DisplayName("stop actually disposes the active subscription (chatStream wiring works)")
void stopsActiveStream() {
controller.createSession(API_KEY, req("visitorA", "s1"));
String cid = WebChatController.deriveConversationId(API_KEY, "visitorA", "s1");
// Simulate what WebChatController.chatStream does right after .subscribe():
// register the run + bind the Disposable so requestStop() can dispose it.
streamTracker.register(cid);
Disposable disposable = Flux.never().subscribe();
streamTracker.setDisposable(cid, disposable);
assertThat(disposable.isDisposed()).isFalse();
R<Map<String, Object>> r = controller.stopSession(API_KEY, tokenFor("visitorA"), "visitorA", "s1");
assertThat(r.getCode()).isEqualTo(200);
assertThat(r.getData().get("stopped")).isEqualTo(Boolean.TRUE);
assertThat(disposable.isDisposed()).isTrue();
}
@Test
@DisplayName("stop returns stopped=false when no stream is active (idempotent)")
void noActiveStreamReturnsFalse() {
controller.createSession(API_KEY, req("visitorB", "s1"));
R<Map<String, Object>> r = controller.stopSession(API_KEY, tokenFor("visitorB"), "visitorB", "s1");
assertThat(r.getCode()).isEqualTo(200);
assertThat(r.getData().get("stopped")).isEqualTo(Boolean.FALSE);
}
@Test
@DisplayName("bad visitor token → 401")
void rejectsBadToken() {
controller.createSession(API_KEY, req("visitorC", "s1"));
R<Map<String, Object>> r = controller.stopSession(API_KEY, "bogus-token", "visitorC", "s1");
assertThat(r.getCode()).isEqualTo(401);
}
@Test
@DisplayName("bad API Key → 401")
void rejectsBadApiKey() {
R<Map<String, Object>> r = controller.stopSession("bogus-key", "any-token", "visitorD", "s1");
assertThat(r.getCode()).isEqualTo(401);
}
@Test
@DisplayName("unknown sessionId → 404 (no namespace probing)")
void rejectsUnknownSession() {
// Visitor exists (token verifies) but never created session "ghost".
R<Map<String, Object>> r = controller.stopSession(
API_KEY, tokenFor("visitorE"), "visitorE", "never-created");
assertThat(r.getCode()).isEqualTo(404);
}
}