diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8476240608..971fcd4cc2a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -753,6 +753,9 @@ importers: p-wait-for: specifier: ^5.0.2 version: 5.0.2 + partial-json: + specifier: ^0.1.7 + version: 0.1.7 pdf-parse: specifier: ^1.1.1 version: 1.1.1 @@ -8113,6 +8116,9 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + partial-json@0.1.7: + resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} + path-data-parser@0.1.0: resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} @@ -14048,7 +14054,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.50)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) '@vitest/utils@3.2.4': dependencies: @@ -17019,7 +17025,7 @@ snapshots: whatwg-encoding: 3.1.1 whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 - ws: 8.18.2 + ws: 8.18.3 xml-name-validator: 5.0.0 transitivePeerDependencies: - bufferutil @@ -18482,6 +18488,8 @@ snapshots: parseurl@1.3.3: {} + partial-json@0.1.7: {} + path-data-parser@0.1.0: {} path-exists@4.0.0: {} diff --git a/src/api/providers/__tests__/minimax.spec.ts b/src/api/providers/__tests__/minimax.spec.ts index 4a993286ea5..45058fb4ffc 100644 --- a/src/api/providers/__tests__/minimax.spec.ts +++ b/src/api/providers/__tests__/minimax.spec.ts @@ -372,11 +372,13 @@ describe("MiniMaxHandler", () => { const firstChunk = await stream.next() expect(firstChunk.done).toBe(false) + // Provider now yields tool_call_partial chunks, NativeToolCallParser handles reassembly expect(firstChunk.value).toEqual({ - type: "tool_call", + type: "tool_call_partial", + index: 0, id: "tool-123", name: "get_weather", - arguments: JSON.stringify({ city: "London" }), + arguments: undefined, }) }) }) diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index 05b3e405f2e..31fdaa23899 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -269,13 +269,31 @@ describe("OpenAiHandler", () => { chunks.push(chunk) } - const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call") - expect(toolCallChunks).toHaveLength(1) - expect(toolCallChunks[0]).toEqual({ - type: "tool_call", + // Provider now yields tool_call_partial chunks, NativeToolCallParser handles reassembly + const toolCallPartialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") + expect(toolCallPartialChunks).toHaveLength(3) + // First chunk has id and name + expect(toolCallPartialChunks[0]).toEqual({ + type: "tool_call_partial", + index: 0, id: "call_1", name: "test_tool", - arguments: '{"arg":"value"}', + arguments: "", + }) + // Subsequent chunks have arguments + expect(toolCallPartialChunks[1]).toEqual({ + type: "tool_call_partial", + index: 0, + id: undefined, + name: undefined, + arguments: '{"arg":', + }) + expect(toolCallPartialChunks[2]).toEqual({ + type: "tool_call_partial", + index: 0, + id: undefined, + name: undefined, + arguments: '"value"}', }) }) @@ -318,11 +336,12 @@ describe("OpenAiHandler", () => { chunks.push(chunk) } - // Tool calls should still be yielded via the fallback mechanism - const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call") - expect(toolCallChunks).toHaveLength(1) - expect(toolCallChunks[0]).toEqual({ - type: "tool_call", + // Provider now yields tool_call_partial chunks, NativeToolCallParser handles reassembly + const toolCallPartialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") + expect(toolCallPartialChunks).toHaveLength(1) + expect(toolCallPartialChunks[0]).toEqual({ + type: "tool_call_partial", + index: 0, id: "call_fallback", name: "fallback_tool", arguments: '{"test":"fallback"}', @@ -819,12 +838,21 @@ describe("OpenAiHandler", () => { chunks.push(chunk) } - const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call") - expect(toolCallChunks).toHaveLength(1) - expect(toolCallChunks[0]).toEqual({ - type: "tool_call", + // Provider now yields tool_call_partial chunks, NativeToolCallParser handles reassembly + const toolCallPartialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") + expect(toolCallPartialChunks).toHaveLength(2) + expect(toolCallPartialChunks[0]).toEqual({ + type: "tool_call_partial", + index: 0, id: "call_1", name: "test_tool", + arguments: "", + }) + expect(toolCallPartialChunks[1]).toEqual({ + type: "tool_call_partial", + index: 0, + id: undefined, + name: undefined, arguments: "{}", }) }) @@ -870,11 +898,12 @@ describe("OpenAiHandler", () => { chunks.push(chunk) } - // Tool calls should still be yielded via the fallback mechanism - const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call") - expect(toolCallChunks).toHaveLength(1) - expect(toolCallChunks[0]).toEqual({ - type: "tool_call", + // Provider now yields tool_call_partial chunks, NativeToolCallParser handles reassembly + const toolCallPartialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") + expect(toolCallPartialChunks).toHaveLength(1) + expect(toolCallPartialChunks[0]).toEqual({ + type: "tool_call_partial", + index: 0, id: "call_o3_fallback", name: "o3_fallback_tool", arguments: '{"o3":"test"}', diff --git a/src/api/providers/__tests__/roo.spec.ts b/src/api/providers/__tests__/roo.spec.ts index 9dc9aff3db8..458fa228636 100644 --- a/src/api/providers/__tests__/roo.spec.ts +++ b/src/api/providers/__tests__/roo.spec.ts @@ -636,7 +636,7 @@ describe("RooHandler", () => { handler = new RooHandler(mockOptions) }) - it("should yield tool calls when finish_reason is tool_calls", async () => { + it("should yield raw tool call chunks when tool_calls present", async () => { mockCreate.mockResolvedValueOnce({ [Symbol.asyncIterator]: async function* () { yield { @@ -689,14 +689,27 @@ describe("RooHandler", () => { chunks.push(chunk) } - const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call") - expect(toolCallChunks).toHaveLength(1) - expect(toolCallChunks[0].id).toBe("call_123") - expect(toolCallChunks[0].name).toBe("read_file") - expect(toolCallChunks[0].arguments).toBe('{"path":"test.ts"}') + // Verify we get raw tool call chunks + const rawChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") + + expect(rawChunks).toHaveLength(2) + expect(rawChunks[0]).toEqual({ + type: "tool_call_partial", + index: 0, + id: "call_123", + name: "read_file", + arguments: '{"path":"', + }) + expect(rawChunks[1]).toEqual({ + type: "tool_call_partial", + index: 0, + id: undefined, + name: undefined, + arguments: 'test.ts"}', + }) }) - it("should yield tool calls even when finish_reason is not set (fallback behavior)", async () => { + it("should yield raw tool call chunks even when finish_reason is not tool_calls", async () => { mockCreate.mockResolvedValueOnce({ [Symbol.asyncIterator]: async function* () { yield { @@ -718,12 +731,11 @@ describe("RooHandler", () => { }, ], } - // Stream ends without finish_reason being set to "tool_calls" yield { choices: [ { delta: {}, - finish_reason: "stop", // Different finish reason + finish_reason: "stop", index: 0, }, ], @@ -738,15 +750,19 @@ describe("RooHandler", () => { chunks.push(chunk) } - // Tool calls should still be yielded via the fallback mechanism - const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call") - expect(toolCallChunks).toHaveLength(1) - expect(toolCallChunks[0].id).toBe("call_456") - expect(toolCallChunks[0].name).toBe("write_to_file") - expect(toolCallChunks[0].arguments).toBe('{"path":"test.ts","content":"hello"}') + const rawChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") + + expect(rawChunks).toHaveLength(1) + expect(rawChunks[0]).toEqual({ + type: "tool_call_partial", + index: 0, + id: "call_456", + name: "write_to_file", + arguments: '{"path":"test.ts","content":"hello"}', + }) }) - it("should handle multiple tool calls", async () => { + it("should handle multiple tool calls with different indices", async () => { mockCreate.mockResolvedValueOnce({ [Symbol.asyncIterator]: async function* () { yield { @@ -800,15 +816,16 @@ describe("RooHandler", () => { chunks.push(chunk) } - const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call") - expect(toolCallChunks).toHaveLength(2) - expect(toolCallChunks[0].id).toBe("call_1") - expect(toolCallChunks[0].name).toBe("read_file") - expect(toolCallChunks[1].id).toBe("call_2") - expect(toolCallChunks[1].name).toBe("read_file") + const rawChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") + + expect(rawChunks).toHaveLength(2) + expect(rawChunks[0].index).toBe(0) + expect(rawChunks[0].id).toBe("call_1") + expect(rawChunks[1].index).toBe(1) + expect(rawChunks[1].id).toBe("call_2") }) - it("should accumulate tool call arguments across multiple chunks", async () => { + it("should emit raw chunks for streaming arguments", async () => { mockCreate.mockResolvedValueOnce({ [Symbol.asyncIterator]: async function* () { yield { @@ -876,14 +893,15 @@ describe("RooHandler", () => { chunks.push(chunk) } - const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call") - expect(toolCallChunks).toHaveLength(1) - expect(toolCallChunks[0].id).toBe("call_789") - expect(toolCallChunks[0].name).toBe("execute_command") - expect(toolCallChunks[0].arguments).toBe('{"command":"npm install"}') + const rawChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") + + expect(rawChunks).toHaveLength(3) + expect(rawChunks[0].arguments).toBe('{"command":"') + expect(rawChunks[1].arguments).toBe("npm install") + expect(rawChunks[2].arguments).toBe('"}') }) - it("should not yield empty tool calls when no tool calls present", async () => { + it("should not yield tool call chunks when no tool calls present", async () => { mockCreate.mockResolvedValueOnce({ [Symbol.asyncIterator]: async function* () { yield { @@ -902,8 +920,8 @@ describe("RooHandler", () => { chunks.push(chunk) } - const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call") - expect(toolCallChunks).toHaveLength(0) + const rawChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") + expect(rawChunks).toHaveLength(0) }) }) }) diff --git a/src/api/providers/base-openai-compatible-provider.ts b/src/api/providers/base-openai-compatible-provider.ts index 3d78ef75d16..2c183b7fc8d 100644 --- a/src/api/providers/base-openai-compatible-provider.ts +++ b/src/api/providers/base-openai-compatible-provider.ts @@ -123,8 +123,6 @@ export abstract class BaseOpenAiCompatibleProvider }) as const, ) - const toolCallAccumulator = new Map() - let lastUsage: OpenAI.CompletionUsage | undefined for await (const chunk of stream) { @@ -137,7 +135,6 @@ export abstract class BaseOpenAiCompatibleProvider } const delta = chunk.choices?.[0]?.delta - const finishReason = chunk.choices?.[0]?.finish_reason if (delta?.content) { for (const processedChunk of matcher.update(delta.content)) { @@ -157,35 +154,17 @@ export abstract class BaseOpenAiCompatibleProvider } } + // Emit raw tool call chunks - NativeToolCallParser handles state management if (delta?.tool_calls) { for (const toolCall of delta.tool_calls) { - const index = toolCall.index - const existing = toolCallAccumulator.get(index) - - if (existing) { - if (toolCall.function?.arguments) { - existing.arguments += toolCall.function.arguments - } - } else { - toolCallAccumulator.set(index, { - id: toolCall.id || "", - name: toolCall.function?.name || "", - arguments: toolCall.function?.arguments || "", - }) - } - } - } - - if (finishReason === "tool_calls") { - for (const toolCall of toolCallAccumulator.values()) { yield { - type: "tool_call", + type: "tool_call_partial", + index: toolCall.index, id: toolCall.id, - name: toolCall.name, - arguments: toolCall.arguments, + name: toolCall.function?.name, + arguments: toolCall.function?.arguments, } } - toolCallAccumulator.clear() } if (chunk.usage) { @@ -193,20 +172,6 @@ export abstract class BaseOpenAiCompatibleProvider } } - // Fallback: If stream ends with accumulated tool calls that weren't yielded - // (e.g., finish_reason was 'stop' or 'length' instead of 'tool_calls') - if (toolCallAccumulator.size > 0) { - for (const toolCall of toolCallAccumulator.values()) { - yield { - type: "tool_call", - id: toolCall.id, - name: toolCall.name, - arguments: toolCall.arguments, - } - } - toolCallAccumulator.clear() - } - if (lastUsage) { yield this.processUsageMetrics(lastUsage, this.getModel().info) } diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index 89c816e8157..545b7f7f17d 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -236,13 +236,30 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl yield { type: "reasoning", text: part.text } } } else if (part.functionCall) { - const callId = `${part.functionCall.name}-${toolCallCounter++}` + // Gemini sends complete function calls in a single chunk + // Emit as partial chunks for consistent handling with NativeToolCallParser + const callId = `${part.functionCall.name}-${toolCallCounter}` + const args = JSON.stringify(part.functionCall.args) + + // Emit name first yield { - type: "tool_call", + type: "tool_call_partial", + index: toolCallCounter, id: callId, name: part.functionCall.name, - arguments: JSON.stringify(part.functionCall.args), + arguments: undefined, + } + + // Then emit arguments + yield { + type: "tool_call_partial", + index: toolCallCounter, + id: callId, + name: undefined, + arguments: args, } + + toolCallCounter++ } else { // This is regular content if (part.text) { diff --git a/src/api/providers/minimax.ts b/src/api/providers/minimax.ts index 03767644c66..023a9780074 100644 --- a/src/api/providers/minimax.ts +++ b/src/api/providers/minimax.ts @@ -120,9 +120,6 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand let cacheWriteTokens = 0 let cacheReadTokens = 0 - // Track tool calls being accumulated via streaming - const toolCallAccumulator = new Map() - for await (const chunk of stream) { switch (chunk.type) { case "message_start": { @@ -180,16 +177,14 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand yield { type: "text", text: chunk.content_block.text } break case "tool_use": { - // Tool use block started - store initial data - // If input is empty ({}), start with empty string as deltas will build it - // Otherwise, stringify the initial input as a base for potential deltas - const initialInput = chunk.content_block.input || {} - const hasInitialContent = Object.keys(initialInput).length > 0 - toolCallAccumulator.set(chunk.index, { + // Emit initial tool call partial with id and name + yield { + type: "tool_call_partial", + index: chunk.index, id: chunk.content_block.id, name: chunk.content_block.name, - input: hasInitialContent ? JSON.stringify(initialInput) : "", - }) + arguments: undefined, + } break } } @@ -203,31 +198,22 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand yield { type: "text", text: chunk.delta.text } break case "input_json_delta": { - // Accumulate tool input JSON as it streams - const existingToolCall = toolCallAccumulator.get(chunk.index) - if (existingToolCall) { - existingToolCall.input += chunk.delta.partial_json + // Emit tool call partial chunks as arguments stream in + yield { + type: "tool_call_partial", + index: chunk.index, + id: undefined, + name: undefined, + arguments: chunk.delta.partial_json, } break } } break - case "content_block_stop": { - // Block is complete - yield tool call if this was a tool_use block - const completedToolCall = toolCallAccumulator.get(chunk.index) - if (completedToolCall) { - yield { - type: "tool_call", - id: completedToolCall.id, - name: completedToolCall.name, - arguments: completedToolCall.input, - } - // Remove from accumulator after yielding - toolCallAccumulator.delete(chunk.index) - } + case "content_block_stop": + // Block is complete - no action needed, NativeToolCallParser handles completion break - } } } diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index 74ba621c43d..34829d0810f 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -34,8 +34,6 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio private lastResponseOutput: any[] | undefined // Last top-level response id from Responses API (for troubleshooting) private lastResponseId: string | undefined - // Accumulate partial tool calls: call_id -> { name, arguments } - private currentToolCalls: Map = new Map() // Abort controller for cancelling ongoing requests private abortController?: AbortController @@ -153,8 +151,6 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio this.lastResponseOutput = undefined // Reset last response id for this request this.lastResponseId = undefined - // Reset tool call accumulator - this.currentToolCalls.clear() // Use Responses API for ALL models const { verbosity, reasoning } = this.getModel() @@ -1070,48 +1066,32 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio return } - // Handle tool/function call deltas and completion + // Handle tool/function call deltas - emit as partial chunks if ( event?.type === "response.tool_call_arguments.delta" || event?.type === "response.function_call_arguments.delta" ) { + // Emit partial chunks directly - NativeToolCallParser handles state management const callId = event.call_id || event.tool_call_id || event.id - if (callId) { - if (!this.currentToolCalls.has(callId)) { - this.currentToolCalls.set(callId, { name: "", arguments: "" }) - } - const toolCall = this.currentToolCalls.get(callId)! - - // Update name if present (usually in the first delta) - if (event.name || event.function_name) { - toolCall.name = event.name || event.function_name - } - - // Append arguments delta - if (event.delta || event.arguments) { - toolCall.arguments += event.delta || event.arguments - } + const name = event.name || event.function_name + const args = event.delta || event.arguments + + yield { + type: "tool_call_partial", + index: event.index ?? 0, + id: callId, + name, + arguments: args, } return } + // Handle tool/function call completion events if ( event?.type === "response.tool_call_arguments.done" || event?.type === "response.function_call_arguments.done" ) { - const callId = event.call_id || event.tool_call_id || event.id - if (callId && this.currentToolCalls.has(callId)) { - const toolCall = this.currentToolCalls.get(callId)! - // Yield the complete tool call - yield { - type: "tool_call", - id: callId, - name: toolCall.name, - arguments: toolCall.arguments, - } - // Remove from accumulator - this.currentToolCalls.delete(callId) - } + // Tool call complete - no action needed, NativeToolCallParser handles completion return } @@ -1135,8 +1115,9 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio event.type === "response.output_item.done" // Only handle done events for tool calls to ensure arguments are complete ) { // Handle complete tool/function call item + // Emit as tool_call for backward compatibility with non-streaming tool handling const callId = item.call_id || item.tool_call_id || item.id - if (callId && !this.currentToolCalls.has(callId)) { + if (callId) { const args = item.arguments || item.function?.arguments || item.function_arguments yield { type: "tool_call", @@ -1152,19 +1133,6 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio // Completion events that may carry usage if (event?.type === "response.done" || event?.type === "response.completed") { - // Yield any pending tool calls that didn't get a 'done' event (fallback) - if (this.currentToolCalls.size > 0) { - for (const [callId, toolCall] of this.currentToolCalls) { - yield { - type: "tool_call", - id: callId, - name: toolCall.name, - arguments: toolCall.arguments || "{}", - } - } - this.currentToolCalls.clear() - } - const usage = event?.response?.usage || event?.usage || undefined const usageData = this.normalizeUsage(usage, model) if (usageData) { diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 1c8d3c7d9d1..e9109b0d7f2 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -191,11 +191,9 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl ) let lastUsage - const toolCallAccumulator = new Map() for await (const chunk of stream) { const delta = chunk.choices?.[0]?.delta ?? {} - const finishReason = chunk.choices?.[0]?.finish_reason if (delta.content) { for (const chunk of matcher.update(delta.content)) { @@ -212,33 +210,14 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl if (delta.tool_calls) { for (const toolCall of delta.tool_calls) { - const index = toolCall.index - const existing = toolCallAccumulator.get(index) - - if (existing) { - if (toolCall.function?.arguments) { - existing.arguments += toolCall.function.arguments - } - } else { - toolCallAccumulator.set(index, { - id: toolCall.id || "", - name: toolCall.function?.name || "", - arguments: toolCall.function?.arguments || "", - }) - } - } - } - - if (finishReason === "tool_calls") { - for (const toolCall of toolCallAccumulator.values()) { yield { - type: "tool_call", + type: "tool_call_partial", + index: toolCall.index, id: toolCall.id, - name: toolCall.name, - arguments: toolCall.arguments, + name: toolCall.function?.name, + arguments: toolCall.function?.arguments, } } - toolCallAccumulator.clear() } if (chunk.usage) { @@ -246,20 +225,6 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } } - // Fallback: If stream ends with accumulated tool calls that weren't yielded - // (e.g., finish_reason was 'stop' or 'length' instead of 'tool_calls') - if (toolCallAccumulator.size > 0) { - for (const toolCall of toolCallAccumulator.values()) { - yield { - type: "tool_call", - id: toolCall.id, - name: toolCall.name, - arguments: toolCall.arguments, - } - } - toolCallAccumulator.clear() - } - for (const chunk of matcher.final()) { yield chunk } @@ -466,11 +431,8 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } private async *handleStreamResponse(stream: AsyncIterable): ApiStream { - const toolCallAccumulator = new Map() - for await (const chunk of stream) { const delta = chunk.choices?.[0]?.delta - const finishReason = chunk.choices?.[0]?.finish_reason if (delta) { if (delta.content) { @@ -480,38 +442,20 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } } + // Emit raw tool call chunks - NativeToolCallParser handles state management if (delta.tool_calls) { for (const toolCall of delta.tool_calls) { - const index = toolCall.index - const existing = toolCallAccumulator.get(index) - - if (existing) { - if (toolCall.function?.arguments) { - existing.arguments += toolCall.function.arguments - } - } else { - toolCallAccumulator.set(index, { - id: toolCall.id || "", - name: toolCall.function?.name || "", - arguments: toolCall.function?.arguments || "", - }) + yield { + type: "tool_call_partial", + index: toolCall.index, + id: toolCall.id, + name: toolCall.function?.name, + arguments: toolCall.function?.arguments, } } } } - if (finishReason === "tool_calls") { - for (const toolCall of toolCallAccumulator.values()) { - yield { - type: "tool_call", - id: toolCall.id, - name: toolCall.name, - arguments: toolCall.arguments, - } - } - toolCallAccumulator.clear() - } - if (chunk.usage) { yield { type: "usage", @@ -520,20 +464,6 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } } } - - // Fallback: If stream ends with accumulated tool calls that weren't yielded - // (e.g., finish_reason was 'stop' or 'length' instead of 'tool_calls') - if (toolCallAccumulator.size > 0) { - for (const toolCall of toolCallAccumulator.values()) { - yield { - type: "tool_call", - id: toolCall.id, - name: toolCall.name, - arguments: toolCall.arguments, - } - } - toolCallAccumulator.clear() - } } private _getUrlHost(baseUrl?: string): string { diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index f1ef0b56efb..34329af16c6 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -248,7 +248,6 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } let lastUsage: CompletionUsage | undefined = undefined - const toolCallAccumulator = new Map() // Accumulator for reasoning_details: accumulate text by type-index key const reasoningDetailsAccumulator = new Map< string, @@ -346,24 +345,15 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH yield { type: "reasoning", text: delta.reasoning } } - // Check for tool calls in delta + // Emit raw tool call chunks - NativeToolCallParser handles state management if ("tool_calls" in delta && Array.isArray(delta.tool_calls)) { for (const toolCall of delta.tool_calls) { - const index = toolCall.index - const existing = toolCallAccumulator.get(index) - - if (existing) { - // Accumulate arguments for existing tool call - if (toolCall.function?.arguments) { - existing.arguments += toolCall.function.arguments - } - } else { - // Start new tool call accumulation - toolCallAccumulator.set(index, { - id: toolCall.id || "", - name: toolCall.function?.name || "", - arguments: toolCall.function?.arguments || "", - }) + yield { + type: "tool_call_partial", + index: toolCall.index, + id: toolCall.id, + name: toolCall.function?.name, + arguments: toolCall.function?.arguments, } } } @@ -373,39 +363,11 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } } - // When finish_reason is 'tool_calls', yield all accumulated tool calls - if (finishReason === "tool_calls" && toolCallAccumulator.size > 0) { - for (const toolCall of toolCallAccumulator.values()) { - yield { - type: "tool_call", - id: toolCall.id, - name: toolCall.name, - arguments: toolCall.arguments, - } - } - // Clear accumulator after yielding - toolCallAccumulator.clear() - } - if (chunk.usage) { lastUsage = chunk.usage } } - // Fallback: If stream ends with accumulated tool calls that weren't yielded - // (e.g., finish_reason was 'stop' or 'length' instead of 'tool_calls') - if (toolCallAccumulator.size > 0) { - for (const toolCall of toolCallAccumulator.values()) { - yield { - type: "tool_call", - id: toolCall.id, - name: toolCall.name, - arguments: toolCall.arguments, - } - } - toolCallAccumulator.clear() - } - // After streaming completes, store the accumulated reasoning_details if (reasoningDetailsAccumulator.size > 0) { this.currentReasoningDetails = Array.from(reasoningDetailsAccumulator.values()) diff --git a/src/api/providers/roo.ts b/src/api/providers/roo.ts index 393740d3bd4..ce643e27cee 100644 --- a/src/api/providers/roo.ts +++ b/src/api/providers/roo.ts @@ -126,12 +126,9 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { ) let lastUsage: RooUsage | undefined = undefined - // Accumulate tool calls by index - similar to how reasoning accumulates - const toolCallAccumulator = new Map() for await (const chunk of stream) { const delta = chunk.choices[0]?.delta - const finishReason = chunk.choices[0]?.finish_reason if (delta) { // Check for reasoning content (similar to OpenRouter) @@ -150,24 +147,15 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { } } - // Check for tool calls in delta + // Emit raw tool call chunks - NativeToolCallParser handles state management if ("tool_calls" in delta && Array.isArray(delta.tool_calls)) { for (const toolCall of delta.tool_calls) { - const index = toolCall.index - const existing = toolCallAccumulator.get(index) - - if (existing) { - // Accumulate arguments for existing tool call - if (toolCall.function?.arguments) { - existing.arguments += toolCall.function.arguments - } - } else { - // Start new tool call accumulation - toolCallAccumulator.set(index, { - id: toolCall.id || "", - name: toolCall.function?.name || "", - arguments: toolCall.function?.arguments || "", - }) + yield { + type: "tool_call_partial", + index: toolCall.index, + id: toolCall.id, + name: toolCall.function?.name, + arguments: toolCall.function?.arguments, } } } @@ -180,39 +168,11 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { } } - // When finish_reason is 'tool_calls', yield all accumulated tool calls - if (finishReason === "tool_calls" && toolCallAccumulator.size > 0) { - for (const [index, toolCall] of toolCallAccumulator.entries()) { - yield { - type: "tool_call", - id: toolCall.id, - name: toolCall.name, - arguments: toolCall.arguments, - } - } - // Clear accumulator after yielding - toolCallAccumulator.clear() - } - if (chunk.usage) { lastUsage = chunk.usage as RooUsage } } - // Fallback: If stream ends with accumulated tool calls that weren't yielded - // (e.g., finish_reason was 'stop' or 'length' instead of 'tool_calls') - if (toolCallAccumulator.size > 0) { - for (const [index, toolCall] of toolCallAccumulator.entries()) { - yield { - type: "tool_call", - id: toolCall.id, - name: toolCall.name, - arguments: toolCall.arguments, - } - } - toolCallAccumulator.clear() - } - if (lastUsage) { // Check if the current model is marked as free const model = this.getModel() diff --git a/src/api/transform/stream.ts b/src/api/transform/stream.ts index cd6c3a56a72..a4a0fe4a9a7 100644 --- a/src/api/transform/stream.ts +++ b/src/api/transform/stream.ts @@ -6,6 +6,10 @@ export type ApiStreamChunk = | ApiStreamReasoningChunk | ApiStreamGroundingChunk | ApiStreamToolCallChunk + | ApiStreamToolCallStartChunk + | ApiStreamToolCallDeltaChunk + | ApiStreamToolCallEndChunk + | ApiStreamToolCallPartialChunk | ApiStreamError export interface ApiStreamError { @@ -46,6 +50,36 @@ export interface ApiStreamToolCallChunk { arguments: string } +export interface ApiStreamToolCallStartChunk { + type: "tool_call_start" + id: string + name: string +} + +export interface ApiStreamToolCallDeltaChunk { + type: "tool_call_delta" + id: string + delta: string +} + +export interface ApiStreamToolCallEndChunk { + type: "tool_call_end" + id: string +} + +/** + * Raw tool call chunk from the API stream. + * Providers emit this simple format; NativeToolCallParser handles all state management + * (tracking, buffering, emitting start/delta/end events). + */ +export interface ApiStreamToolCallPartialChunk { + type: "tool_call_partial" + index: number + id?: string + name?: string + arguments?: string +} + export interface GroundingSource { title: string url: string diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index c463d4a5cd0..c27ecbcc72a 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -1,5 +1,11 @@ import { type ToolName, toolNames, type FileEntry } from "@roo-code/types" import { type ToolUse, type ToolParamName, toolParamNames, type NativeToolArgs } from "../../shared/tools" +import { parseJSON } from "partial-json" +import type { + ApiStreamToolCallStartChunk, + ApiStreamToolCallDeltaChunk, + ApiStreamToolCallEndChunk, +} from "../../api/transform/stream" /** * Helper type to extract properly typed native arguments for a given tool. @@ -16,7 +22,353 @@ type NativeArgsFor = TName extends keyof NativeToolArgs * typed arguments via nativeArgs. Tool-specific handlers should consume * nativeArgs directly rather than relying on synthesized legacy params. */ +/** + * Event types returned from raw chunk processing. + */ +export type ToolCallStreamEvent = ApiStreamToolCallStartChunk | ApiStreamToolCallDeltaChunk | ApiStreamToolCallEndChunk + +/** + * Parser for native tool calls (OpenAI-style function calling). + * Converts native tool call format to ToolUse format for compatibility + * with existing tool execution infrastructure. + * + * For tools with refactored parsers (e.g., read_file), this parser provides + * typed arguments via nativeArgs. Tool-specific handlers should consume + * nativeArgs directly rather than relying on synthesized legacy params. + * + * This class also handles raw tool call chunk processing, converting + * provider-level raw chunks into start/delta/end events. + */ export class NativeToolCallParser { + // Streaming state management for argument accumulation (keyed by tool call id) + private static streamingToolCalls = new Map< + string, + { + id: string + name: ToolName + argumentsAccumulator: string + } + >() + + // Raw chunk tracking state (keyed by index from API stream) + private static rawChunkTracker = new Map< + number, + { + id: string + name: string + hasStarted: boolean + deltaBuffer: string[] + } + >() + + /** + * Process a raw tool call chunk from the API stream. + * Handles tracking, buffering, and emits start/delta/end events. + * + * This is the entry point for providers that emit tool_call_partial chunks. + * Returns an array of events to be processed by the consumer. + */ + public static processRawChunk(chunk: { + index: number + id?: string + name?: string + arguments?: string + }): ToolCallStreamEvent[] { + const events: ToolCallStreamEvent[] = [] + const { index, id, name, arguments: args } = chunk + + let tracked = this.rawChunkTracker.get(index) + + // Initialize new tool call tracking when we receive an id + if (id && !tracked) { + tracked = { + id, + name: name || "", + hasStarted: false, + deltaBuffer: [], + } + this.rawChunkTracker.set(index, tracked) + } + + if (!tracked) { + return events + } + + // Update name if present in chunk and not yet set + if (name) { + tracked.name = name + } + + // Emit start event when we have the name + if (!tracked.hasStarted && tracked.name) { + events.push({ + type: "tool_call_start", + id: tracked.id, + name: tracked.name, + }) + tracked.hasStarted = true + + // Flush buffered deltas + for (const bufferedDelta of tracked.deltaBuffer) { + events.push({ + type: "tool_call_delta", + id: tracked.id, + delta: bufferedDelta, + }) + } + tracked.deltaBuffer = [] + } + + // Emit delta event for argument chunks + if (args) { + if (tracked.hasStarted) { + events.push({ + type: "tool_call_delta", + id: tracked.id, + delta: args, + }) + } else { + tracked.deltaBuffer.push(args) + } + } + + return events + } + + /** + * Process stream finish reason. + * Emits end events when finish_reason is 'tool_calls'. + */ + public static processFinishReason(finishReason: string | null | undefined): ToolCallStreamEvent[] { + const events: ToolCallStreamEvent[] = [] + + if (finishReason === "tool_calls" && this.rawChunkTracker.size > 0) { + for (const [, tracked] of this.rawChunkTracker.entries()) { + events.push({ + type: "tool_call_end", + id: tracked.id, + }) + } + this.rawChunkTracker.clear() + } + + return events + } + + /** + * Finalize any remaining tool calls that weren't explicitly ended. + * Should be called at the end of stream processing. + */ + public static finalizeRawChunks(): ToolCallStreamEvent[] { + const events: ToolCallStreamEvent[] = [] + + if (this.rawChunkTracker.size > 0) { + for (const [, tracked] of this.rawChunkTracker.entries()) { + if (tracked.hasStarted) { + events.push({ + type: "tool_call_end", + id: tracked.id, + }) + } + } + this.rawChunkTracker.clear() + } + + return events + } + + /** + * Clear all raw chunk tracking state. + * Should be called when a new API request starts. + */ + public static clearRawChunkState(): void { + this.rawChunkTracker.clear() + } + + /** + * Start streaming a new tool call. + * Initializes tracking for incremental argument parsing. + */ + public static startStreamingToolCall(id: string, name: ToolName): void { + this.streamingToolCalls.set(id, { + id, + name, + argumentsAccumulator: "", + }) + } + + /** + * Clear all streaming tool call state. + * Should be called when a new API request starts to prevent memory leaks + * from interrupted streams. + */ + public static clearAllStreamingToolCalls(): void { + this.streamingToolCalls.clear() + } + + /** + * Check if there are any active streaming tool calls. + * Useful for debugging and testing. + */ + public static hasActiveStreamingToolCalls(): boolean { + return this.streamingToolCalls.size > 0 + } + + /** + * Process a chunk of JSON arguments for a streaming tool call. + * Uses partial-json-parser to extract values from incomplete JSON immediately. + * Returns a partial ToolUse with currently parsed parameters. + */ + public static processStreamingChunk(id: string, chunk: string): ToolUse | null { + const toolCall = this.streamingToolCalls.get(id) + if (!toolCall) { + console.warn(`[NativeToolCallParser] Received chunk for unknown tool call: ${id}`) + return null + } + + // Accumulate the JSON string + toolCall.argumentsAccumulator += chunk + + // Parse whatever we can from the incomplete JSON! + // partial-json-parser extracts partial values (strings, arrays, objects) immediately + try { + const partialArgs = parseJSON(toolCall.argumentsAccumulator) + + // Create partial ToolUse with extracted values + return this.createPartialToolUse( + toolCall.id, + toolCall.name, + partialArgs || {}, + true, // partial + ) + } catch { + // Even partial-json-parser can fail on severely malformed JSON + // Return null and wait for next chunk + return null + } + } + + /** + * Finalize a streaming tool call. + * Parses the complete JSON and returns the final ToolUse. + */ + public static finalizeStreamingToolCall(id: string): ToolUse | null { + const toolCall = this.streamingToolCalls.get(id) + if (!toolCall) { + console.warn(`[NativeToolCallParser] Attempting to finalize unknown tool call: ${id}`) + return null + } + + // Parse the complete accumulated JSON + const finalToolUse = this.parseToolCall({ + id: toolCall.id, + name: toolCall.name, + arguments: toolCall.argumentsAccumulator, + }) + + // Clean up streaming state + this.streamingToolCalls.delete(id) + + return finalToolUse + } + + /** + * Create a partial ToolUse from currently parsed arguments. + * Used during streaming to show progress. + */ + private static createPartialToolUse( + id: string, + name: ToolName, + partialArgs: Record, + partial: boolean, + ): ToolUse | null { + // Build legacy params for display + // NOTE: For streaming partial updates, we MUST populate params even for complex types + // because tool.handlePartial() methods rely on params to show UI updates + const params: Partial> = {} + + for (const [key, value] of Object.entries(partialArgs)) { + if (toolParamNames.includes(key as ToolParamName)) { + params[key as ToolParamName] = typeof value === "string" ? value : JSON.stringify(value) + } + } + + // Build partial nativeArgs based on what we have so far + let nativeArgs: any = undefined + + switch (name) { + case "read_file": + if (partialArgs.files && Array.isArray(partialArgs.files)) { + nativeArgs = { files: partialArgs.files } + } + break + + case "attempt_completion": + if (partialArgs.result) { + nativeArgs = { result: partialArgs.result } + } + break + + case "execute_command": + if (partialArgs.command) { + nativeArgs = { + command: partialArgs.command, + cwd: partialArgs.cwd, + } + } + break + + case "insert_content": + // For partial tool calls, we build nativeArgs incrementally as fields arrive. + // Unlike parseToolCall which validates all required fields, partial parsing + // needs to show progress as each field streams in. + if ( + partialArgs.path !== undefined || + partialArgs.line !== undefined || + partialArgs.content !== undefined + ) { + nativeArgs = { + path: partialArgs.path, + line: + typeof partialArgs.line === "number" + ? partialArgs.line + : partialArgs.line !== undefined + ? parseInt(String(partialArgs.line), 10) + : undefined, + content: partialArgs.content, + } + } + break + + case "write_to_file": + if (partialArgs.path || partialArgs.content || partialArgs.line_count !== undefined) { + nativeArgs = { + path: partialArgs.path, + content: partialArgs.content, + line_count: + typeof partialArgs.line_count === "number" + ? partialArgs.line_count + : partialArgs.line_count + ? parseInt(String(partialArgs.line_count), 10) + : undefined, + } + } + break + + // Add other tools as needed + default: + break + } + + return { + type: "tool_use" as const, + name, + params, + partial, + nativeArgs, + } + } + /** * Convert a native tool call chunk to a ToolUse object. * diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 6c17e201210..f7b0adc28ab 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -63,7 +63,7 @@ import { ClineApiReqCancelReason, ClineApiReqInfo } from "../../shared/Extension import { getApiMetrics, hasTokenUsageChanged } from "../../shared/getApiMetrics" import { ClineAskResponse } from "../../shared/WebviewMessage" import { defaultModeSlug, getModeBySlug, getGroupName } from "../../shared/modes" -import { DiffStrategy, type ToolUse } from "../../shared/tools" +import { DiffStrategy, type ToolUse, type ToolParamName, toolParamNames } from "../../shared/tools" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { getModelMaxOutputTokens } from "../../shared/api" @@ -306,6 +306,9 @@ export class Task extends EventEmitter implements TaskLike { assistantMessageParser?: AssistantMessageParser private providerProfileChangeListener?: (config: { name: string; provider?: string }) => void + // Native tool call streaming state (track which index each tool is at) + private streamingToolCallIndices: Map = new Map() + // Cached model info for current streaming session (set at start of each API request) // This prevents excessive getModel() calls during tool execution cachedStreamingModel?: { id: string; info: ModelInfo } @@ -2249,6 +2252,10 @@ export class Task extends EventEmitter implements TaskLike { this.presentAssistantMessageLocked = false this.presentAssistantMessageHasPendingUpdates = false this.assistantMessageParser?.reset() + this.streamingToolCallIndices.clear() + // Clear any leftover streaming tool call state from previous interrupted streams + NativeToolCallParser.clearAllStreamingToolCalls() + NativeToolCallParser.clearRawChunkState() await this.diffViewProvider.reset() @@ -2336,7 +2343,99 @@ export class Task extends EventEmitter implements TaskLike { pendingGroundingSources.push(...chunk.sources) } break + case "tool_call_partial": { + // Process raw tool call chunk through NativeToolCallParser + // which handles tracking, buffering, and emits events + const events = NativeToolCallParser.processRawChunk({ + index: chunk.index, + id: chunk.id, + name: chunk.name, + arguments: chunk.arguments, + }) + + for (const event of events) { + if (event.type === "tool_call_start") { + // Initialize streaming in NativeToolCallParser + NativeToolCallParser.startStreamingToolCall(event.id, event.name as ToolName) + + // Before adding a new tool, finalize any preceding text block + // This prevents the text block from blocking tool presentation + const lastBlock = + this.assistantMessageContent[this.assistantMessageContent.length - 1] + if (lastBlock?.type === "text" && lastBlock.partial) { + lastBlock.partial = false + } + + // Track the index where this tool will be stored + const toolUseIndex = this.assistantMessageContent.length + this.streamingToolCallIndices.set(event.id, toolUseIndex) + + // Create initial partial tool use + const partialToolUse: ToolUse = { + type: "tool_use", + name: event.name as ToolName, + params: {}, + partial: true, + } + + // Store the ID for native protocol + ;(partialToolUse as any).id = event.id + + // Add to content and present + this.assistantMessageContent.push(partialToolUse) + this.userMessageContentReady = false + presentAssistantMessage(this) + } else if (event.type === "tool_call_delta") { + // Process chunk using streaming JSON parser + const partialToolUse = NativeToolCallParser.processStreamingChunk( + event.id, + event.delta, + ) + + if (partialToolUse) { + // Get the index for this tool call + const toolUseIndex = this.streamingToolCallIndices.get(event.id) + if (toolUseIndex !== undefined) { + // Store the ID for native protocol + ;(partialToolUse as any).id = event.id + + // Update the existing tool use with new partial data + this.assistantMessageContent[toolUseIndex] = partialToolUse + + // Present updated tool use + presentAssistantMessage(this) + } + } + } else if (event.type === "tool_call_end") { + // Finalize the streaming tool call + const finalToolUse = NativeToolCallParser.finalizeStreamingToolCall(event.id) + + if (finalToolUse) { + // Store the tool call ID + ;(finalToolUse as any).id = event.id + + // Get the index and replace partial with final + const toolUseIndex = this.streamingToolCallIndices.get(event.id) + if (toolUseIndex !== undefined) { + this.assistantMessageContent[toolUseIndex] = finalToolUse + } + + // Clean up tracking + this.streamingToolCallIndices.delete(event.id) + + // Mark that we have new content to process + this.userMessageContentReady = false + + // Present the finalized tool call + presentAssistantMessage(this) + } + } + } + break + } + case "tool_call": { + // Legacy: Handle complete tool calls (for backward compatibility) // Convert native tool call to ToolUse format const toolUse = NativeToolCallParser.parseToolCall({ id: chunk.id, diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index d6989c103ef..9dff9502bd7 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -719,6 +719,13 @@ export class ReadFileTool extends BaseTool<"read_file"> { filePath = legacyPath } + if (!filePath && block.nativeArgs && "files" in block.nativeArgs && Array.isArray(block.nativeArgs.files)) { + const files = block.nativeArgs.files + if (files.length > 0 && files[0]?.path) { + filePath = files[0].path + } + } + const fullPath = filePath ? path.resolve(task.cwd, filePath) : "" const sharedMessageProps: ClineSayTool = { tool: "readFile", diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 4c355beb073..310d26b117d 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -290,7 +290,7 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { const relPath: string | undefined = block.params.path let newContent: string | undefined = block.params.content - if (!relPath || newContent === undefined) { + if (!relPath) { return } @@ -321,7 +321,7 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { const sharedMessageProps: ClineSayTool = { tool: fileExists ? "editedExistingFile" : "newFileCreated", path: getReadablePath(task.cwd, relPath), - content: newContent, + content: newContent || "", isOutsideWorkspace, isProtected: isWriteProtected, } @@ -329,14 +329,16 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { const partialMessage = JSON.stringify(sharedMessageProps) await task.ask("tool", partialMessage, block.partial).catch(() => {}) - if (!task.diffViewProvider.isEditing) { - await task.diffViewProvider.open(relPath) - } + if (newContent) { + if (!task.diffViewProvider.isEditing) { + await task.diffViewProvider.open(relPath) + } - await task.diffViewProvider.update( - everyLineHasLineNumbers(newContent) ? stripLineNumbers(newContent) : newContent, - false, - ) + await task.diffViewProvider.update( + everyLineHasLineNumbers(newContent) ? stripLineNumbers(newContent) : newContent, + false, + ) + } } } diff --git a/src/package.json b/src/package.json index fab82fa7caf..b22c206a61d 100644 --- a/src/package.json +++ b/src/package.json @@ -501,6 +501,7 @@ "os-name": "^6.0.0", "p-limit": "^6.2.0", "p-wait-for": "^5.0.2", + "partial-json": "^0.1.7", "pdf-parse": "^1.1.1", "pkce-challenge": "^5.0.0", "pretty-bytes": "^7.0.0",