This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new e2f6808cd fix(ai): report premature run-stream EOF (#5187)
e2f6808cd is described below

commit e2f6808cdedf5d5aa7f10c41c6cdadd3b0d0ab7d
Author: zmuxuny <[email protected]>
AuthorDate: Fri Oct 9 23:39:28 2026 -0700

    fix(ai): report premature run-stream EOF (#5187)
    
    The server can end a run stream through `detach()` / `completeWithError` 
without sending a `done`
    frame, and the frontend resolved the reader silently at EOF, so a truncated 
run looked like a normal
    completion. EOF now throws on the single shared read path.
    
    `ai.test.ts` 34 tests green.
---
 web/src/api/ai.test.ts | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++
 web/src/api/ai.ts      |  7 ++++++
 2 files changed, 72 insertions(+)

diff --git a/web/src/api/ai.test.ts b/web/src/api/ai.test.ts
index 6029a35d4..2de42feee 100644
--- a/web/src/api/ai.test.ts
+++ b/web/src/api/ai.test.ts
@@ -174,6 +174,71 @@ describe('AI API', () => {
       expect(events[4]).toEqual({ type: 'text_delta', content: '集群当前有 2 个 
broker。' });
     });
 
+    it.each(['send', 'attach'] as const)(
+      'rejectsPrematureEofAfterPartialAgentOutputFor%sTest',
+      async (operation) => {
+        vi.stubGlobal(
+          'fetch',
+          vi
+            .fn()
+            .mockResolvedValue(
+              eventStreamResponse([agentFrame({ type: 'text_delta', content: 
'partial' })]),
+            ),
+        );
+        const onEvent = vi.fn();
+        const stream =
+          operation === 'send'
+            ? openRunStream(7, body, { onEvent })
+            : attachRunStream(41, 0, { onEvent });
+
+        await expect(stream).rejects.toMatchObject({
+          name: 'AiStreamError',
+          code: 'llm.stream.unexpected_eof',
+        });
+        expect(onEvent).toHaveBeenCalledExactlyOnceWith({
+          type: 'text_delta',
+          content: 'partial',
+        });
+      },
+    );
+
+    it.each([
+      ['empty', ''],
+      ['heartbeat-only', ':hb\n\n'],
+      ['unterminated-agent', 'event: agent\ndata: 
{"type":"text_delta","content":"partial"}'],
+    ])('rejectsPrematureEofFor%sResponseTest', async (_name, content) => {
+      vi.stubGlobal('fetch', 
vi.fn().mockResolvedValue(eventStreamResponse([content])));
+
+      await expect(attachRunStream(41, 0, { onEvent: vi.fn() 
})).rejects.toMatchObject({
+        name: 'AiStreamError',
+        code: 'llm.stream.unexpected_eof',
+      });
+    });
+
+    it('acceptsATerminalControlFrameWithoutATrailingBlankLineTest', async () 
=> {
+      vi.stubGlobal(
+        'fetch',
+        vi.fn().mockResolvedValue(eventStreamResponse(['event: done\ndata: 
{}'])),
+      );
+
+      await expect(attachRunStream(41, 0, { onEvent: vi.fn() 
})).resolves.toBeUndefined();
+    });
+
+    it('preservesReaderAbortErrorsInsteadOfReplacingThemWithPrematureEofTest', 
async () => {
+      const abort = new DOMException('The operation was aborted.', 
'AbortError');
+      const response = new Response(
+        new ReadableStream<Uint8Array>({
+          start(controller) {
+            controller.error(abort);
+          },
+        }),
+        { headers: { 'content-type': 'text/event-stream' } },
+      );
+      vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response));
+
+      await expect(attachRunStream(41, 0, { onEvent: vi.fn() 
})).rejects.toBe(abort);
+    });
+
     it('terminatesOnTheDoneEventNameAndCancelsTheReaderTest', async () => {
       const onCancel = vi.fn();
       vi.stubGlobal(
diff --git a/web/src/api/ai.ts b/web/src/api/ai.ts
index 1b7d2aade..dd8df34f4 100644
--- a/web/src/api/ai.ts
+++ b/web/src/api/ai.ts
@@ -285,6 +285,13 @@ async function consumeEventStream(
     // A server that closes without a trailing blank line still delivered its 
last frame.
     buffer += decoder.decode();
     if (buffer && dispatch(buffer)) return;
+    // A run outlives its HTTP observer. Only the explicit terminal control 
frame
+    // establishes normal stream completion; EOF can leave the server run 
active.
+    throw new AiStreamError(
+      'AI stream ended before the done event',
+      'llm.stream.unexpected_eof',
+      'Reload the conversation to recover the persisted run and reconnect if 
it is still active.',
+    );
   } finally {
     await reader.cancel().catch(() => undefined);
   }

Reply via email to