diff --git a/web/src/core/messages/merge-message.ts b/web/src/core/messages/merge-message.ts index 6877546..8f1bf3e 100644 --- a/web/src/core/messages/merge-message.ts +++ b/web/src/core/messages/merge-message.ts @@ -1,6 +1,8 @@ // Copyright (c) 2025 Bytedance Ltd. and/or its affiliates // SPDX-License-Identifier: MIT +import { parse as bestEffortParse } from "best-effort-json-parser"; + import type { ChatEvent, InterruptEvent, @@ -13,6 +15,34 @@ import { deepClone } from "../utils/deep-clone"; import type { Message } from "./types"; +/** + * Safely parse JSON from streamed tool call argument chunks. + * Uses best-effort-json-parser to handle incomplete JSON from streaming. + * This addresses issue #528 where MCP tool call arguments may be incomplete + * when using stream_mode="messages". + */ +function safeParseToolArgs(argsString: string): Record { + try { + // First try standard JSON.parse for complete JSON + return JSON.parse(argsString) as Record; + } catch { + // If standard parsing fails, use best-effort parser for incomplete JSON + try { + const result = bestEffortParse(argsString); + // Ensure we return an object + if (result && typeof result === "object" && !Array.isArray(result)) { + return result as Record; + } + // If parsing returns something unexpected, wrap in an object + return { _parsed: result }; + } catch { + // If all parsing fails, return empty object + console.warn("Failed to parse tool call arguments:", argsString); + return {}; + } + } +} + export function mergeMessage(message: Message, event: ChatEvent) { if (event.type === "message_chunk") { mergeTextMessage(message, event); @@ -29,7 +59,7 @@ export function mergeMessage(message: Message, event: ChatEvent) { if (message.toolCalls) { message.toolCalls.forEach((toolCall) => { if (toolCall.argsChunks?.length) { - toolCall.args = JSON.parse(toolCall.argsChunks.join("")); + toolCall.args = safeParseToolArgs(toolCall.argsChunks.join("")); delete toolCall.argsChunks; } }); diff --git a/web/tests/merge-message.test.ts b/web/tests/merge-message.test.ts new file mode 100644 index 0000000..430e817 --- /dev/null +++ b/web/tests/merge-message.test.ts @@ -0,0 +1,333 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT + +/** + * Tests for Issue #528 Fix: MCP Tool Call Argument Parsing + * + * These tests verify: + * - Complete JSON tool call arguments parsing + * - Incomplete JSON tool call arguments handling (using best-effort-json-parser) + * - Multiple argument chunks merging + * - Escaped character conversion in tool call arguments + */ + +import { mergeMessage } from "../src/core/messages/merge-message"; +import type { Message, ToolCallRuntime } from "../src/core/messages/types"; +import type { + ChatEvent, + ToolCallChunksEvent, + ToolCallsEvent, +} from "../src/core/api/types"; + +function createBaseMessage(): Message { + return { + id: "test-msg-1", + threadId: "thread-1", + role: "assistant", + content: "", + contentChunks: [], + isStreaming: true, + agent: "researcher", + }; +} + +function createToolCall( + overrides: Partial & { id: string; name: string } +): ToolCallRuntime { + return { + args: {}, + ...overrides, + }; +} + +describe("mergeMessage", () => { + describe("tool call argument parsing", () => { + it("should parse complete JSON tool call arguments", () => { + const message = createBaseMessage(); + message.toolCalls = [ + createToolCall({ + id: "call-1", + name: "search", + argsChunks: ['{"query": "test query"}'], + }), + ]; + + const event: ChatEvent = { + type: "message_chunk", + data: { + id: "test-msg-1", + thread_id: "thread-1", + agent: "researcher", + role: "assistant", + finish_reason: "tool_calls", + }, + }; + + const result = mergeMessage(message, event); + expect(result.toolCalls?.[0]?.args).toEqual({ query: "test query" }); + expect(result.toolCalls?.[0]?.argsChunks).toBeUndefined(); + }); + + it("should handle incomplete JSON tool call arguments (issue #528)", () => { + const message = createBaseMessage(); + // Simulate incomplete JSON from streaming - missing closing brace + message.toolCalls = [ + createToolCall({ + id: "call-1", + name: "search", + argsChunks: ['{"query": "test query"'], + }), + ]; + + const event: ChatEvent = { + type: "message_chunk", + data: { + id: "test-msg-1", + thread_id: "thread-1", + agent: "researcher", + role: "assistant", + finish_reason: "tool_calls", + }, + }; + + // Should not throw an error and should attempt to parse + const result = mergeMessage(message, event); + expect(result.toolCalls?.[0]?.args).toBeDefined(); + expect(result.toolCalls?.[0]?.argsChunks).toBeUndefined(); + }); + + it("should handle multiple argument chunks", () => { + const message = createBaseMessage(); + message.toolCalls = [ + createToolCall({ + id: "call-1", + name: "search", + argsChunks: ['{"query":', ' "test', ' query"', "}"], + }), + ]; + + const event: ChatEvent = { + type: "message_chunk", + data: { + id: "test-msg-1", + thread_id: "thread-1", + agent: "researcher", + role: "assistant", + finish_reason: "tool_calls", + }, + }; + + const result = mergeMessage(message, event); + expect(result.toolCalls?.[0]?.args).toEqual({ query: "test query" }); + }); + + it("should handle incomplete nested JSON", () => { + const message = createBaseMessage(); + // Simulate incomplete nested JSON from streaming + message.toolCalls = [ + createToolCall({ + id: "call-1", + name: "complex_tool", + argsChunks: ['{"query": "test", "options": {"limit": 10'], + }), + ]; + + const event: ChatEvent = { + type: "message_chunk", + data: { + id: "test-msg-1", + thread_id: "thread-1", + agent: "researcher", + role: "assistant", + finish_reason: "tool_calls", + }, + }; + + // Should not throw an error + const result = mergeMessage(message, event); + expect(result.toolCalls?.[0]?.args).toBeDefined(); + expect(result.toolCalls?.[0]?.argsChunks).toBeUndefined(); + }); + + it("should handle empty args string gracefully", () => { + const message = createBaseMessage(); + message.toolCalls = [ + createToolCall({ + id: "call-1", + name: "simple_tool", + argsChunks: [""], + }), + ]; + + const event: ChatEvent = { + type: "message_chunk", + data: { + id: "test-msg-1", + thread_id: "thread-1", + agent: "researcher", + role: "assistant", + finish_reason: "tool_calls", + }, + }; + + // Should not throw an error + const result = mergeMessage(message, event); + expect(result.toolCalls?.[0]?.args).toBeDefined(); + }); + + it("should handle MCP tool call with complex arguments", () => { + const message = createBaseMessage(); + // Simulate MCP tool with complex arguments, partially streamed + message.toolCalls = [ + createToolCall({ + id: "call-1", + name: "mcp_github_tool", + argsChunks: [ + '{"repo": "bytedance/deer-flow", "action": "get_trending", "params": {"limit": 5, "language": "python"', + ], + }), + ]; + + const event: ChatEvent = { + type: "message_chunk", + data: { + id: "test-msg-1", + thread_id: "thread-1", + agent: "researcher", + role: "assistant", + finish_reason: "tool_calls", + }, + }; + + const result = mergeMessage(message, event); + const args = result.toolCalls?.[0]?.args; + expect(args).toBeDefined(); + // The best-effort parser should recover the available data + expect(args?.repo).toBe("bytedance/deer-flow"); + }); + }); + + describe("tool call chunks merging", () => { + it("should accumulate tool call chunks", () => { + const message = createBaseMessage(); + + // First chunk with tool call ID + const event1: ToolCallsEvent = { + type: "tool_calls", + data: { + id: "test-msg-1", + thread_id: "thread-1", + agent: "researcher", + role: "assistant", + tool_calls: [ + { + type: "tool_call", + id: "call-1", + name: "search", + args: { query: "test" }, + }, + ], + tool_call_chunks: [ + { + type: "tool_call_chunk", + index: 0, + id: "call-1", + name: "search", + args: '{"query": "test"}', + }, + ], + }, + }; + + const result1 = mergeMessage(message, event1); + expect(result1.toolCalls).toBeDefined(); + expect(result1.toolCalls?.length).toBe(1); + }); + + it("should convert escaped characters and parse args end-to-end", () => { + const message = createBaseMessage(); + + // First event: tool call with escaped JSON characters + const event1: ToolCallsEvent = { + type: "tool_calls", + data: { + id: "test-msg-1", + thread_id: "thread-1", + agent: "researcher", + role: "assistant", + tool_calls: [ + { + type: "tool_call", + id: "call-1", + name: "search", + args: { query: "test" }, + }, + ], + tool_call_chunks: [ + { + type: "tool_call_chunk", + index: 0, + id: "call-1", + name: "search", + args: '{"query": "test"}', + }, + ], + }, + }; + + const result1 = mergeMessage(message, event1); + // Verify escaped chars were converted in argsChunks + expect(result1.toolCalls?.[0]?.argsChunks?.[0]).toBe('{"query": "test"}'); + + // Second event: finish reason triggers safeParseToolArgs + const finishEvent: ChatEvent = { + type: "message_chunk", + data: { + id: "test-msg-1", + thread_id: "thread-1", + agent: "researcher", + role: "assistant", + finish_reason: "tool_calls", + }, + }; + + const result2 = mergeMessage(result1, finishEvent); + // Verify args were successfully parsed + expect(result2.toolCalls?.[0]?.args).toEqual({ query: "test" }); + expect(result2.toolCalls?.[0]?.argsChunks).toBeUndefined(); + }); + + it("should convert escaped characters in args", () => { + const message = createBaseMessage(); + message.toolCalls = [ + createToolCall({ + id: "call-1", + name: "search", + }), + ]; + + const event: ToolCallChunksEvent = { + type: "tool_call_chunks", + data: { + id: "test-msg-1", + thread_id: "thread-1", + agent: "researcher", + role: "assistant", + tool_call_chunks: [ + { + type: "tool_call_chunk", + index: 0, + id: "call-1", + name: "search", + args: "{[test]}", + }, + ], + }, + }; + + const result = mergeMessage(message, event); + // The argsChunks should have converted the escaped chars + expect(result.toolCalls?.[0]?.argsChunks?.[0]).toBe("{[test]}"); + }); + }); +});