From 84030c287766c493e7ac77e6079dc086a521b529 Mon Sep 17 00:00:00 2001 From: majiayu000 <1835304752@qq.com> Date: Thu, 25 Dec 2025 11:14:33 +0800 Subject: [PATCH 1/3] fix(web): add null check for SSE stream bufferObj to prevent TypeError When JSON.parse returns null or a non-object value during SSE stream processing, accessing properties on bufferObj would throw TypeError. This fix adds a validation check after JSON parsing to ensure bufferObj is a valid object before accessing its properties. fixes #29166 Signed-off-by: majiayu000 <1835304752@qq.com> --- web/service/base.spec.ts | 231 +++++++++++++++++++++++++++++++++++++++ web/service/base.ts | 11 ++ 2 files changed, 242 insertions(+) create mode 100644 web/service/base.spec.ts diff --git a/web/service/base.spec.ts b/web/service/base.spec.ts new file mode 100644 index 0000000000..36fe99869c --- /dev/null +++ b/web/service/base.spec.ts @@ -0,0 +1,231 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { handleStream } from './base' + +describe('handleStream', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Invalid response data handling', () => { + it('should handle null bufferObj from JSON.parse gracefully', async () => { + // Arrange + const onData = vi.fn() + const onCompleted = vi.fn() + + // Create a mock response that returns 'data: null' + const mockReader = { + read: vi.fn() + .mockResolvedValueOnce({ + done: false, + value: new TextEncoder().encode('data: null\n'), + }) + .mockResolvedValueOnce({ + done: true, + value: undefined, + }), + } + + const mockResponse = { + ok: true, + body: { + getReader: () => mockReader, + }, + } as unknown as Response + + // Act + handleStream(mockResponse, onData, onCompleted) + + // Wait for the stream to be processed + await new Promise(resolve => setTimeout(resolve, 50)) + + // Assert + expect(onData).toHaveBeenCalledWith('', false, { + conversationId: undefined, + messageId: '', + errorMessage: 'Invalid response data', + errorCode: 'invalid_data', + }) + expect(onCompleted).toHaveBeenCalledWith(true, 'Invalid response data') + }) + + it('should handle non-object bufferObj from JSON.parse gracefully', async () => { + // Arrange + const onData = vi.fn() + const onCompleted = vi.fn() + + // Create a mock response that returns a primitive value + const mockReader = { + read: vi.fn() + .mockResolvedValueOnce({ + done: false, + value: new TextEncoder().encode('data: "string"\n'), + }) + .mockResolvedValueOnce({ + done: true, + value: undefined, + }), + } + + const mockResponse = { + ok: true, + body: { + getReader: () => mockReader, + }, + } as unknown as Response + + // Act + handleStream(mockResponse, onData, onCompleted) + + // Wait for the stream to be processed + await new Promise(resolve => setTimeout(resolve, 50)) + + // Assert + expect(onData).toHaveBeenCalledWith('', false, { + conversationId: undefined, + messageId: '', + errorMessage: 'Invalid response data', + errorCode: 'invalid_data', + }) + expect(onCompleted).toHaveBeenCalledWith(true, 'Invalid response data') + }) + + it('should handle valid message event correctly', async () => { + // Arrange + const onData = vi.fn() + const onCompleted = vi.fn() + + const validMessage = { + event: 'message', + answer: 'Hello world', + conversation_id: 'conv-123', + task_id: 'task-456', + id: 'msg-789', + } + + const mockReader = { + read: vi.fn() + .mockResolvedValueOnce({ + done: false, + value: new TextEncoder().encode(`data: ${JSON.stringify(validMessage)}\n`), + }) + .mockResolvedValueOnce({ + done: true, + value: undefined, + }), + } + + const mockResponse = { + ok: true, + body: { + getReader: () => mockReader, + }, + } as unknown as Response + + // Act + handleStream(mockResponse, onData, onCompleted) + + // Wait for the stream to be processed + await new Promise(resolve => setTimeout(resolve, 50)) + + // Assert + expect(onData).toHaveBeenCalledWith('Hello world', true, { + conversationId: 'conv-123', + taskId: 'task-456', + messageId: 'msg-789', + }) + expect(onCompleted).toHaveBeenCalled() + }) + + it('should handle error status 400 correctly', async () => { + // Arrange + const onData = vi.fn() + const onCompleted = vi.fn() + + const errorMessage = { + status: 400, + message: 'Bad request', + code: 'bad_request', + } + + const mockReader = { + read: vi.fn() + .mockResolvedValueOnce({ + done: false, + value: new TextEncoder().encode(`data: ${JSON.stringify(errorMessage)}\n`), + }) + .mockResolvedValueOnce({ + done: true, + value: undefined, + }), + } + + const mockResponse = { + ok: true, + body: { + getReader: () => mockReader, + }, + } as unknown as Response + + // Act + handleStream(mockResponse, onData, onCompleted) + + // Wait for the stream to be processed + await new Promise(resolve => setTimeout(resolve, 50)) + + // Assert + expect(onData).toHaveBeenCalledWith('', false, { + conversationId: undefined, + messageId: '', + errorMessage: 'Bad request', + errorCode: 'bad_request', + }) + expect(onCompleted).toHaveBeenCalledWith(true, 'Bad request') + }) + + it('should handle malformed JSON gracefully', async () => { + // Arrange + const onData = vi.fn() + const onCompleted = vi.fn() + + const mockReader = { + read: vi.fn() + .mockResolvedValueOnce({ + done: false, + value: new TextEncoder().encode('data: {invalid json}\n'), + }) + .mockResolvedValueOnce({ + done: true, + value: undefined, + }), + } + + const mockResponse = { + ok: true, + body: { + getReader: () => mockReader, + }, + } as unknown as Response + + // Act + handleStream(mockResponse, onData, onCompleted) + + // Wait for the stream to be processed + await new Promise(resolve => setTimeout(resolve, 50)) + + // Assert - malformed JSON triggers the catch block which calls onData and returns + expect(onData).toHaveBeenCalled() + expect(onCompleted).toHaveBeenCalled() + }) + + it('should throw error when response is not ok', () => { + // Arrange + const onData = vi.fn() + const mockResponse = { + ok: false, + } as unknown as Response + + // Act & Assert + expect(() => handleStream(mockResponse, onData)).toThrow('Network response was not ok') + }) + }) +}) diff --git a/web/service/base.ts b/web/service/base.ts index d9f3dba53a..0d3d15c77c 100644 --- a/web/service/base.ts +++ b/web/service/base.ts @@ -217,6 +217,17 @@ export const handleStream = ( }) return } + if (!bufferObj || typeof bufferObj !== 'object') { + onData('', false, { + conversationId: undefined, + messageId: '', + errorMessage: 'Invalid response data', + errorCode: 'invalid_data', + }) + hasError = true + onCompleted?.(true, 'Invalid response data') + return + } if (bufferObj.status === 400 || !bufferObj.event) { onData('', false, { conversationId: undefined, From e1854b657ef5167c4b62282605b33a814e947d0d Mon Sep 17 00:00:00 2001 From: crazywoola <100913391+crazywoola@users.noreply.github.com> Date: Fri, 26 Dec 2025 11:19:50 +0800 Subject: [PATCH 2/3] Apply suggestions from code review Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- web/service/base.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/service/base.spec.ts b/web/service/base.spec.ts index 36fe99869c..d6ed242ed9 100644 --- a/web/service/base.spec.ts +++ b/web/service/base.spec.ts @@ -39,7 +39,7 @@ describe('handleStream', () => { await new Promise(resolve => setTimeout(resolve, 50)) // Assert - expect(onData).toHaveBeenCalledWith('', false, { + expect(onData).toHaveBeenCalledWith('', true, { conversationId: undefined, messageId: '', errorMessage: 'Invalid response data', @@ -80,7 +80,7 @@ describe('handleStream', () => { await new Promise(resolve => setTimeout(resolve, 50)) // Assert - expect(onData).toHaveBeenCalledWith('', false, { + expect(onData).toHaveBeenCalledWith('', true, { conversationId: undefined, messageId: '', errorMessage: 'Invalid response data', From 1c8a9491d8b29e9daaad7bc6e5c8bca572ba89bf Mon Sep 17 00:00:00 2001 From: majiayu000 <1835304752@qq.com> Date: Fri, 26 Dec 2025 19:11:22 +0800 Subject: [PATCH 3/3] fix(web): use isFirstMessage instead of hardcoded false for invalid data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The onData callback's second parameter is isFirstMessage, not isError. When encountering invalid response data (null or non-object), we should preserve the current isFirstMessage state rather than hardcoding false. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- web/service/base.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/service/base.ts b/web/service/base.ts index 0d3d15c77c..2ab115f96c 100644 --- a/web/service/base.ts +++ b/web/service/base.ts @@ -218,7 +218,7 @@ export const handleStream = ( return } if (!bufferObj || typeof bufferObj !== 'object') { - onData('', false, { + onData('', isFirstMessage, { conversationId: undefined, messageId: '', errorMessage: 'Invalid response data',