diff --git a/src/main/java/io/github/imfangs/dify/client/callback/BaseStreamCallback.java b/src/main/java/io/github/imfangs/dify/client/callback/BaseStreamCallback.java
index eddf98e..bd94171 100644
--- a/src/main/java/io/github/imfangs/dify/client/callback/BaseStreamCallback.java
+++ b/src/main/java/io/github/imfangs/dify/client/callback/BaseStreamCallback.java
@@ -33,4 +33,13 @@ default void onPing(PingEvent event) {
default void onException(Throwable throwable) {
}
+ /**
+ * SDK 已完成对 SSE 响应的读取。
+ *
+ *
当收到客户端定义的终止事件,或服务端正常关闭 SSE 连接时触发。该回调仅表示协议流读取结束,
+ * 不表示 Dify 工作流或调用方业务处理成功。Dify {@code error} 事件和网络异常不会触发该回调。
+ */
+ default void onStreamComplete() {
+ }
+
}
diff --git a/src/main/java/io/github/imfangs/dify/client/impl/AbstractDifyClient.java b/src/main/java/io/github/imfangs/dify/client/impl/AbstractDifyClient.java
index f77c3c3..caa5bba 100644
--- a/src/main/java/io/github/imfangs/dify/client/impl/AbstractDifyClient.java
+++ b/src/main/java/io/github/imfangs/dify/client/impl/AbstractDifyClient.java
@@ -491,6 +491,23 @@ protected interface LineProcessor {
boolean process(String line);
}
+ /**
+ * 带结束原因的行处理器。
+ */
+ @FunctionalInterface
+ protected interface StreamLineProcessor {
+ StreamLineResult process(String line);
+ }
+
+ /**
+ * SSE 行处理结果。
+ */
+ protected enum StreamLineResult {
+ CONTINUE,
+ COMPLETE,
+ ERROR
+ }
+
/**
* 事件处理器
*/
@@ -514,6 +531,25 @@ protected void executeStreamRequest(String path, Object body, LineProcessor line
executeStreamCall(httpRequest, lineProcessor, errorHandler);
}
+ /**
+ * 执行 POST 流式请求,并在正常结束时通知调用方。
+ */
+ protected void executeStreamRequest(String path,
+ Object body,
+ StreamLineProcessor lineProcessor,
+ Consumer errorHandler,
+ Runnable completionHandler) {
+ RequestBody requestBody = createJsonRequestBody(body);
+ Request httpRequest = new Request.Builder()
+ .url(baseUrl + path)
+ .post(requestBody)
+ .header("Authorization", "Bearer " + apiKey)
+ .header("Content-Type", "application/json")
+ .header("Accept", "text/event-stream")
+ .build();
+ executeStreamCall(httpRequest, lineProcessor, errorHandler, completionHandler);
+ }
+
/**
* 执行 GET 流式请求
*/
@@ -527,7 +563,33 @@ protected void executeGetStreamRequest(String path, LineProcessor lineProcessor,
executeStreamCall(httpRequest, lineProcessor, errorHandler);
}
+ /**
+ * 执行 GET 流式请求,并在正常结束时通知调用方。
+ */
+ protected void executeGetStreamRequest(String path,
+ StreamLineProcessor lineProcessor,
+ Consumer errorHandler,
+ Runnable completionHandler) {
+ Request httpRequest = new Request.Builder()
+ .url(baseUrl + path)
+ .get()
+ .header("Authorization", "Bearer " + apiKey)
+ .header("Accept", "text/event-stream")
+ .build();
+ executeStreamCall(httpRequest, lineProcessor, errorHandler, completionHandler);
+ }
+
protected void executeStreamCall(Request httpRequest, LineProcessor lineProcessor, Consumer errorHandler) {
+ executeStreamCall(httpRequest,
+ line -> lineProcessor.process(line) ? StreamLineResult.CONTINUE : StreamLineResult.COMPLETE,
+ errorHandler,
+ null);
+ }
+
+ protected void executeStreamCall(Request httpRequest,
+ StreamLineProcessor lineProcessor,
+ Consumer errorHandler,
+ Runnable completionHandler) {
Call call = httpClient.newCall(httpRequest);
call.enqueue(new Callback() {
@Override
@@ -561,13 +623,23 @@ public void onResponse(Call call, Response response) {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(responseBody.byteStream(), StandardCharsets.UTF_8))) {
String line;
+ boolean receivedErrorEvent = false;
while ((line = reader.readLine()) != null) {
if (line.isEmpty()) {
continue;
}
- if (!lineProcessor.process(line)) {
+
+ StreamLineResult result = lineProcessor.process(line);
+ if (result == StreamLineResult.ERROR) {
+ receivedErrorEvent = true;
break;
}
+ if (result == StreamLineResult.COMPLETE) {
+ break;
+ }
+ }
+ if (!receivedErrorEvent && completionHandler != null) {
+ completionHandler.run();
}
}
} catch (Exception e) {
@@ -582,8 +654,18 @@ public void onResponse(Call call, Response response) {
* 处理一行 SSE 数据
*/
protected boolean processStreamLine(String line, BaseStreamCallback callback, Set terminalEvents, EventProcessor eventProcessor) {
+ return processStreamLineWithResult(line, callback, terminalEvents, eventProcessor) == StreamLineResult.CONTINUE;
+ }
+
+ /**
+ * 处理一行 SSE 数据,并保留结束原因。
+ */
+ protected StreamLineResult processStreamLineWithResult(String line,
+ BaseStreamCallback callback,
+ Set terminalEvents,
+ EventProcessor eventProcessor) {
if (line == null || line.trim().isEmpty()) {
- return true;
+ return StreamLineResult.CONTINUE;
}
if (line.startsWith(STREAM_DATA_PREFIX)) {
String data = line.substring(STREAM_DATA_PREFIX.length()).trim();
@@ -591,13 +673,16 @@ protected boolean processStreamLine(String line, BaseStreamCallback callback, Se
BaseEvent baseEvent = JsonUtils.fromJson(data, BaseEvent.class);
if (baseEvent == null) {
log.warn("解析事件数据为null: {}", data);
- return true;
+ return StreamLineResult.CONTINUE;
}
eventProcessor.process(data, baseEvent.getEvent());
String eventTypeStr = baseEvent.getEvent();
EventType eventType = eventTypeStr != null ? EventType.fromValue(eventTypeStr) : null;
+ if (eventType == EventType.ERROR) {
+ return StreamLineResult.ERROR;
+ }
if (eventType != null && terminalEvents.contains(eventType)) {
- return false;
+ return StreamLineResult.COMPLETE;
}
} catch (Exception e) {
log.error("解析事件数据失败: {}", data, e);
@@ -608,6 +693,6 @@ protected boolean processStreamLine(String line, BaseStreamCallback callback, Se
pingEvent.setEvent(EventType.PING.getValue());
callback.onPing(pingEvent);
}
- return true;
+ return StreamLineResult.CONTINUE;
}
}
diff --git a/src/main/java/io/github/imfangs/dify/client/impl/DefaultDifyClient.java b/src/main/java/io/github/imfangs/dify/client/impl/DefaultDifyClient.java
index 0d97f9d..d4073a0 100644
--- a/src/main/java/io/github/imfangs/dify/client/impl/DefaultDifyClient.java
+++ b/src/main/java/io/github/imfangs/dify/client/impl/DefaultDifyClient.java
@@ -18,6 +18,7 @@
import java.io.*;
import java.nio.charset.StandardCharsets;
+import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.Map;
@@ -33,8 +34,9 @@
public class DefaultDifyClient extends DifyBaseClientImpl implements DifyClient {
// 流式响应相关常量
- private static final Set CHAT_TERMINAL_EVENTS = EnumSet.of(EventType.MESSAGE_END, EventType.ERROR);
- private static final Set WORKFLOW_TERMINAL_EVENTS = EnumSet.of(EventType.WORKFLOW_FINISHED, EventType.ERROR);
+ private static final Set CHAT_TERMINAL_EVENTS = EnumSet.of(EventType.MESSAGE_END);
+ private static final Set NO_TERMINAL_EVENTS = Collections.emptySet();
+ private static final Set WORKFLOW_TERMINAL_EVENTS = EnumSet.of(EventType.WORKFLOW_FINISHED);
// API 路径常量
// 对话型应用相关路径
@@ -111,9 +113,9 @@ public void sendChatMessageStream(ChatMessage message, ChatStreamCallback callba
message.setResponseMode(ResponseMode.STREAMING);
// 执行流式请求
- executeStreamRequest(CHAT_MESSAGES_PATH, message, (line) -> processStreamLine(line, callback, CHAT_TERMINAL_EVENTS, (data, eventType) -> {
+ executeStreamRequest(CHAT_MESSAGES_PATH, message, (line) -> processStreamLineWithResult(line, callback, CHAT_TERMINAL_EVENTS, (data, eventType) -> {
StreamEventDispatcher.dispatchChatEvent(callback, data, eventType);
- }), callback::onException);
+ }), callback::onException, callback::onStreamComplete);
}
@Override
@@ -122,10 +124,19 @@ public void sendChatMessageStream(ChatMessage message, ChatflowStreamCallback ca
// 确保请求模式为流式
message.setResponseMode(ResponseMode.STREAMING);
- // 执行流式请求
- executeStreamRequest(CHAT_MESSAGES_PATH, message, (line) -> processStreamLine(line, callback, WORKFLOW_TERMINAL_EVENTS, (data, eventType) -> {
- StreamEventDispatcher.dispatchChatFlowEvent(callback, data, eventType);
- }), callback::onException);
+ ChatflowTerminalState terminalState = new ChatflowTerminalState();
+ executeStreamRequest(CHAT_MESSAGES_PATH, message, (line) -> {
+ StreamLineResult result = processStreamLineWithResult(line, callback, NO_TERMINAL_EVENTS, (data, eventType) -> {
+ StreamEventDispatcher.dispatchChatFlowEvent(callback, data, eventType);
+ terminalState.record(eventType);
+ });
+ if (result != StreamLineResult.CONTINUE) {
+ return result;
+ }
+ return terminalState.hasReceivedAllTerminalEvents()
+ ? StreamLineResult.COMPLETE
+ : StreamLineResult.CONTINUE;
+ }, callback::onException, callback::onStreamComplete);
}
@Override
@@ -290,10 +301,10 @@ public void sendCompletionMessageStream(CompletionRequest request, CompletionStr
request.setResponseMode(ResponseMode.STREAMING);
// 执行流式请求
- executeStreamRequest(COMPLETION_MESSAGES_PATH, request, (line) -> processStreamLine(line, callback, CHAT_TERMINAL_EVENTS, (data, eventType) -> {
+ executeStreamRequest(COMPLETION_MESSAGES_PATH, request, (line) -> processStreamLineWithResult(line, callback, CHAT_TERMINAL_EVENTS, (data, eventType) -> {
// 分发事件
StreamEventDispatcher.dispatchCompletionEvent(callback, data);
- }), callback::onException);
+ }), callback::onException, callback::onStreamComplete);
}
@Override
@@ -326,10 +337,10 @@ public void runWorkflowStream(WorkflowRunRequest request, WorkflowStreamCallback
request.setResponseMode(ResponseMode.STREAMING);
// 执行流式请求
- executeStreamRequest(WORKFLOWS_RUN_PATH, request, (line) -> processStreamLine(line, callback, WORKFLOW_TERMINAL_EVENTS, (data, eventType) -> {
+ executeStreamRequest(WORKFLOWS_RUN_PATH, request, (line) -> processStreamLineWithResult(line, callback, WORKFLOW_TERMINAL_EVENTS, (data, eventType) -> {
// 分发事件
StreamEventDispatcher.dispatchWorkflowEvent(callback, data);
- }), callback::onException);
+ }), callback::onException, callback::onStreamComplete);
}
@Override
@@ -408,9 +419,9 @@ public void streamWorkflowEvents(String workflowRunId,
params.put("continue_on_pause", continueOnPause);
}
String url = buildUrlWithParams(WORKFLOW_EVENTS_PATH + "/" + workflowRunId.trim() + "/events", params);
- executeGetStreamRequest(url, (line) -> processStreamLine(line, callback, WORKFLOW_TERMINAL_EVENTS, (data, eventType) -> {
+ executeGetStreamRequest(url, (line) -> processStreamLineWithResult(line, callback, WORKFLOW_TERMINAL_EVENTS, (data, eventType) -> {
StreamEventDispatcher.dispatchWorkflowEvent(callback, data);
- }), callback::onException);
+ }), callback::onException, callback::onStreamComplete);
}
/**
@@ -554,4 +565,28 @@ private String getFileExtension(String fileName) {
int lastDotIndex = fileName.lastIndexOf('.');
return lastDotIndex > 0 ? fileName.substring(lastDotIndex + 1) : "";
}
+
+ /**
+ * 跟踪 Chatflow 的两个终止事件。
+ *
+ * 不同 Dify 版本可能以不同顺序发送 {@code message_end} 和
+ * {@code workflow_finished}。只有两者都收到时,客户端才主动停止读取;若连接先关闭,
+ * 则由通用的流结束回调处理。
+ */
+ private static final class ChatflowTerminalState {
+ private boolean messageEndReceived;
+ private boolean workflowFinishedReceived;
+
+ private void record(String eventType) {
+ if (EventType.MESSAGE_END.getValue().equals(eventType)) {
+ messageEndReceived = true;
+ } else if (EventType.WORKFLOW_FINISHED.getValue().equals(eventType)) {
+ workflowFinishedReceived = true;
+ }
+ }
+
+ private boolean hasReceivedAllTerminalEvents() {
+ return messageEndReceived && workflowFinishedReceived;
+ }
+ }
}
diff --git a/src/main/java/io/github/imfangs/dify/client/impl/DefaultDifyDatasetsClient.java b/src/main/java/io/github/imfangs/dify/client/impl/DefaultDifyDatasetsClient.java
index e137573..aaa4ab6 100644
--- a/src/main/java/io/github/imfangs/dify/client/impl/DefaultDifyDatasetsClient.java
+++ b/src/main/java/io/github/imfangs/dify/client/impl/DefaultDifyDatasetsClient.java
@@ -635,7 +635,7 @@ public DocumentDownloadUrlResponse getDocumentDownloadUrl(String datasetId, Stri
private static final String PIPELINE_DATASOURCE_NODES_PATH = "/pipeline/datasource/nodes";
private static final String PIPELINE_RUN_PATH = "/pipeline/run";
private static final String PIPELINE_FILE_UPLOAD_PATH = "/datasets/pipeline/file-upload";
- private static final Set PIPELINE_TERMINAL_EVENTS = EnumSet.of(EventType.WORKFLOW_FINISHED, EventType.ERROR);
+ private static final Set PIPELINE_TERMINAL_EVENTS = EnumSet.of(EventType.WORKFLOW_FINISHED);
@Override
public List listPipelineDatasourcePlugins(String datasetId, Boolean isPublished) throws IOException, DifyApiException {
@@ -676,9 +676,10 @@ public void runPipelineDatasourceNodeStream(String datasetId,
log.debug("运行 Pipeline 数据源节点: datasetId={}, nodeId={}, request={}", datasetId, nodeId, request);
String path = DATASETS_PATH + "/" + datasetId + PIPELINE_DATASOURCE_NODES_PATH + "/" + nodeId + "/run";
executeStreamRequest(path, request,
- (line) -> processStreamLine(line, callback, PIPELINE_TERMINAL_EVENTS,
+ (line) -> processStreamLineWithResult(line, callback, PIPELINE_TERMINAL_EVENTS,
(data, eventType) -> StreamEventDispatcher.dispatchWorkflowEvent(callback, data)),
- callback::onException);
+ callback::onException,
+ callback::onStreamComplete);
}
@Override
@@ -719,9 +720,10 @@ public void runPipelineStream(String datasetId,
log.debug("运行 Pipeline(流式模式): datasetId={}", datasetId);
String path = DATASETS_PATH + "/" + datasetId + PIPELINE_RUN_PATH;
executeStreamRequest(path, request,
- (line) -> processStreamLine(line, callback, PIPELINE_TERMINAL_EVENTS,
+ (line) -> processStreamLineWithResult(line, callback, PIPELINE_TERMINAL_EVENTS,
(data, eventType) -> StreamEventDispatcher.dispatchWorkflowEvent(callback, data)),
- callback::onException);
+ callback::onException,
+ callback::onStreamComplete);
}
@Override
diff --git a/src/test/java/io/github/imfangs/dify/client/ChatflowStreamTerminalEventTest.java b/src/test/java/io/github/imfangs/dify/client/ChatflowStreamTerminalEventTest.java
new file mode 100644
index 0000000..3dc060e
--- /dev/null
+++ b/src/test/java/io/github/imfangs/dify/client/ChatflowStreamTerminalEventTest.java
@@ -0,0 +1,131 @@
+package io.github.imfangs.dify.client;
+
+import io.github.imfangs.dify.client.callback.ChatflowStreamCallback;
+import io.github.imfangs.dify.client.event.ErrorEvent;
+import io.github.imfangs.dify.client.event.MessageEndEvent;
+import io.github.imfangs.dify.client.event.WorkflowFinishedEvent;
+import io.github.imfangs.dify.client.impl.DefaultDifyClient;
+import io.github.imfangs.dify.client.model.chat.ChatMessage;
+import okhttp3.MediaType;
+import okhttp3.OkHttpClient;
+import okhttp3.Protocol;
+import okhttp3.Response;
+import okhttp3.ResponseBody;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 验证 Chatflow 的终止事件顺序和连接关闭都能正确结束流处理。
+ */
+class ChatflowStreamTerminalEventTest {
+
+ @Test
+ void shouldDeliverBothTerminalEventsInDocumentedOrder() throws Exception {
+ assertTerminalEvents(
+ "data: {\"event\":\"message_end\",\"message_id\":\"message-1\",\"conversation_id\":\"conversation-1\",\"metadata\":{}}\n\n"
+ + "data: {\"event\":\"workflow_finished\",\"data\":{\"id\":\"workflow-1\",\"status\":\"succeeded\"}}\n\n",
+ Arrays.asList("message_end", "workflow_finished", "stream_complete"));
+ }
+
+ @Test
+ void shouldDeliverBothTerminalEventsInReverseOrder() throws Exception {
+ assertTerminalEvents(
+ "data: {\"event\":\"workflow_finished\",\"data\":{\"id\":\"workflow-1\",\"status\":\"succeeded\"}}\n\n"
+ + "data: {\"event\":\"message_end\",\"message_id\":\"message-1\",\"conversation_id\":\"conversation-1\",\"metadata\":{}}\n\n",
+ Arrays.asList("workflow_finished", "message_end", "stream_complete"));
+ }
+
+ @Test
+ void shouldNotifyCompletionWhenConnectionClosesBeforeAllTerminalEventsArrive() throws Exception {
+ CountDownLatch streamCompleted = new CountDownLatch(1);
+ DefaultDifyClient client = createClient(
+ "data: {\"event\":\"workflow_finished\",\"data\":{\"id\":\"workflow-1\",\"status\":\"succeeded\"}}\n\n");
+
+ client.sendChatMessageStream(ChatMessage.builder().query("test").user("tester").build(),
+ new ChatflowStreamCallback() {
+ @Override
+ public void onStreamComplete() {
+ streamCompleted.countDown();
+ }
+ });
+
+ assertTrue(streamCompleted.await(3, TimeUnit.SECONDS),
+ "未收到全部终止事件时,连接关闭仍应通知流读取结束");
+ }
+
+ @Test
+ void shouldNotNotifyCompletionAfterDifyErrorEvent() throws Exception {
+ CountDownLatch errorReceived = new CountDownLatch(1);
+ CountDownLatch streamCompleted = new CountDownLatch(1);
+ DefaultDifyClient client = createClient(
+ "data: {\"event\":\"error\",\"status\":400,\"code\":\"invalid_param\",\"message\":\"invalid request\"}\n\n");
+
+ client.sendChatMessageStream(ChatMessage.builder().query("test").user("tester").build(),
+ new ChatflowStreamCallback() {
+ @Override
+ public void onError(ErrorEvent event) {
+ errorReceived.countDown();
+ }
+
+ @Override
+ public void onStreamComplete() {
+ streamCompleted.countDown();
+ }
+ });
+
+ assertTrue(errorReceived.await(3, TimeUnit.SECONDS), "应分发 Dify error 事件");
+ assertFalse(streamCompleted.await(300, TimeUnit.MILLISECONDS),
+ "Dify error 事件不应触发正常结束回调");
+ }
+
+ private DefaultDifyClient createClient(String events) {
+ OkHttpClient httpClient = new OkHttpClient.Builder()
+ .addInterceptor(chain -> new Response.Builder()
+ .request(chain.request())
+ .protocol(Protocol.HTTP_1_1)
+ .code(200)
+ .message("OK")
+ .body(ResponseBody.create(MediaType.parse("text/event-stream"), events))
+ .build())
+ .build();
+ return new DefaultDifyClient("http://dify.test", "test-key", httpClient);
+ }
+
+ private void assertTerminalEvents(String events, List expectedEvents) throws Exception {
+ List receivedEvents = Collections.synchronizedList(new ArrayList());
+ CountDownLatch streamCompleted = new CountDownLatch(1);
+ DefaultDifyClient client = createClient(events);
+
+ client.sendChatMessageStream(ChatMessage.builder().query("test").user("tester").build(),
+ new ChatflowStreamCallback() {
+ @Override
+ public void onMessageEnd(MessageEndEvent event) {
+ receivedEvents.add("message_end");
+ }
+
+ @Override
+ public void onWorkflowFinished(WorkflowFinishedEvent event) {
+ receivedEvents.add("workflow_finished");
+ }
+
+ @Override
+ public void onStreamComplete() {
+ receivedEvents.add("stream_complete");
+ streamCompleted.countDown();
+ }
+ });
+
+ assertTrue(streamCompleted.await(3, TimeUnit.SECONDS), "应在两个终止事件都收到后通知流结束");
+ assertEquals(expectedEvents, receivedEvents);
+ }
+}
diff --git a/src/test/java/io/github/imfangs/dify/client/PipelineStreamCompletionTest.java b/src/test/java/io/github/imfangs/dify/client/PipelineStreamCompletionTest.java
new file mode 100644
index 0000000..2f0f727
--- /dev/null
+++ b/src/test/java/io/github/imfangs/dify/client/PipelineStreamCompletionTest.java
@@ -0,0 +1,83 @@
+package io.github.imfangs.dify.client;
+
+import io.github.imfangs.dify.client.callback.WorkflowStreamCallback;
+import io.github.imfangs.dify.client.event.WorkflowFinishedEvent;
+import io.github.imfangs.dify.client.impl.DefaultDifyDatasetsClient;
+import io.github.imfangs.dify.client.model.datasets.DatasourceNodeRunRequest;
+import io.github.imfangs.dify.client.model.datasets.PipelineRunRequest;
+import okhttp3.MediaType;
+import okhttp3.OkHttpClient;
+import okhttp3.Protocol;
+import okhttp3.Response;
+import okhttp3.ResponseBody;
+import org.junit.jupiter.api.Test;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 验证知识库 Pipeline 流也会通知通用的流结束回调。
+ */
+class PipelineStreamCompletionTest {
+
+ private static final String WORKFLOW_FINISHED_EVENT =
+ "data: {\"event\":\"workflow_finished\",\"data\":{\"id\":\"workflow-1\",\"status\":\"succeeded\"}}\n\n";
+
+ @Test
+ void shouldNotifyCompletionForPipelineDatasourceNodeStream() throws Exception {
+ DefaultDifyDatasetsClient client = createClient();
+ AtomicBoolean workflowFinishedReceived = new AtomicBoolean();
+ CountDownLatch streamCompleted = new CountDownLatch(1);
+
+ client.runPipelineDatasourceNodeStream("dataset-1", "node-1",
+ DatasourceNodeRunRequest.builder().build(),
+ createCallback(workflowFinishedReceived, streamCompleted));
+
+ assertTrue(streamCompleted.await(3, TimeUnit.SECONDS), "数据源节点流结束时应通知完成回调");
+ assertTrue(workflowFinishedReceived.get(), "应先分发 workflow_finished 事件");
+ }
+
+ @Test
+ void shouldNotifyCompletionForPipelineStream() throws Exception {
+ DefaultDifyDatasetsClient client = createClient();
+ AtomicBoolean workflowFinishedReceived = new AtomicBoolean();
+ CountDownLatch streamCompleted = new CountDownLatch(1);
+
+ client.runPipelineStream("dataset-1", PipelineRunRequest.builder().build(),
+ createCallback(workflowFinishedReceived, streamCompleted));
+
+ assertTrue(streamCompleted.await(3, TimeUnit.SECONDS), "Pipeline 流结束时应通知完成回调");
+ assertTrue(workflowFinishedReceived.get(), "应先分发 workflow_finished 事件");
+ }
+
+ private WorkflowStreamCallback createCallback(AtomicBoolean workflowFinishedReceived,
+ CountDownLatch streamCompleted) {
+ return new WorkflowStreamCallback() {
+ @Override
+ public void onWorkflowFinished(WorkflowFinishedEvent event) {
+ workflowFinishedReceived.set(true);
+ }
+
+ @Override
+ public void onStreamComplete() {
+ streamCompleted.countDown();
+ }
+ };
+ }
+
+ private DefaultDifyDatasetsClient createClient() {
+ OkHttpClient httpClient = new OkHttpClient.Builder()
+ .addInterceptor(chain -> new Response.Builder()
+ .request(chain.request())
+ .protocol(Protocol.HTTP_1_1)
+ .code(200)
+ .message("OK")
+ .body(ResponseBody.create(MediaType.parse("text/event-stream"), WORKFLOW_FINISHED_EVENT))
+ .build())
+ .build();
+ return new DefaultDifyDatasetsClient("http://dify.test", "test-key", httpClient);
+ }
+}
diff --git a/src/test/java/io/github/imfangs/dify/client/WorkflowEventsStreamCompletionTest.java b/src/test/java/io/github/imfangs/dify/client/WorkflowEventsStreamCompletionTest.java
new file mode 100644
index 0000000..abb0434
--- /dev/null
+++ b/src/test/java/io/github/imfangs/dify/client/WorkflowEventsStreamCompletionTest.java
@@ -0,0 +1,72 @@
+package io.github.imfangs.dify.client;
+
+import io.github.imfangs.dify.client.callback.WorkflowStreamCallback;
+import io.github.imfangs.dify.client.event.WorkflowFinishedEvent;
+import io.github.imfangs.dify.client.impl.DefaultDifyClient;
+import okhttp3.MediaType;
+import okhttp3.OkHttpClient;
+import okhttp3.Protocol;
+import okhttp3.Request;
+import okhttp3.Response;
+import okhttp3.ResponseBody;
+import org.junit.jupiter.api.Test;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 验证工作流事件的 GET SSE 接口保留通用流结束回调。
+ */
+class WorkflowEventsStreamCompletionTest {
+
+ private static final String WORKFLOW_FINISHED_EVENT =
+ "data: {\"event\":\"workflow_finished\",\"data\":{\"id\":\"workflow-1\",\"status\":\"succeeded\"}}\n\n";
+
+ @Test
+ void shouldNotifyCompletionForWorkflowEventStream() throws Exception {
+ AtomicReference requestReference = new AtomicReference();
+ DefaultDifyClient client = createClient(requestReference);
+ AtomicBoolean workflowFinishedReceived = new AtomicBoolean();
+ CountDownLatch streamCompleted = new CountDownLatch(1);
+
+ client.streamWorkflowEvents("workflow-1", "tester", false, false,
+ new WorkflowStreamCallback() {
+ @Override
+ public void onWorkflowFinished(WorkflowFinishedEvent event) {
+ workflowFinishedReceived.set(true);
+ }
+
+ @Override
+ public void onStreamComplete() {
+ streamCompleted.countDown();
+ }
+ });
+
+ assertTrue(streamCompleted.await(3, TimeUnit.SECONDS), "工作流事件流结束时应通知完成回调");
+ assertTrue(workflowFinishedReceived.get(), "应先分发 workflow_finished 事件");
+ assertNotNull(requestReference.get(), "应发起工作流事件请求");
+ assertEquals("GET", requestReference.get().method(), "工作流事件接口必须使用 GET 请求");
+ }
+
+ private DefaultDifyClient createClient(AtomicReference requestReference) {
+ OkHttpClient httpClient = new OkHttpClient.Builder()
+ .addInterceptor(chain -> {
+ requestReference.set(chain.request());
+ return new Response.Builder()
+ .request(chain.request())
+ .protocol(Protocol.HTTP_1_1)
+ .code(200)
+ .message("OK")
+ .body(ResponseBody.create(MediaType.parse("text/event-stream"), WORKFLOW_FINISHED_EVENT))
+ .build();
+ })
+ .build();
+ return new DefaultDifyClient("http://dify.test", "test-key", httpClient);
+ }
+}