From 8c8c410fe4d342a28be3c9f14eb0a193bf7e4c39 Mon Sep 17 00:00:00 2001 From: axb Date: Wed, 2 Jul 2025 22:09:58 +0800 Subject: [PATCH 01/10] use assistantMessageParse class instead of parseAssistantMessage parse state will be saved in the parser, it will not iterate through the message content every time a token arrives --- .../AssistantMessageParser.ts | 237 +++++++++++++ .../__tests__/AssistantMessageParser.spec.ts | 324 ++++++++++++++++++ src/core/task/Task.ts | 16 +- 3 files changed, 574 insertions(+), 3 deletions(-) create mode 100644 src/core/assistant-message/AssistantMessageParser.ts create mode 100644 src/core/assistant-message/__tests__/AssistantMessageParser.spec.ts diff --git a/src/core/assistant-message/AssistantMessageParser.ts b/src/core/assistant-message/AssistantMessageParser.ts new file mode 100644 index 00000000000..173267452c4 --- /dev/null +++ b/src/core/assistant-message/AssistantMessageParser.ts @@ -0,0 +1,237 @@ +import { type ToolName, toolNames } from "@roo-code/types" +import { TextContent, ToolUse, ToolParamName, toolParamNames } from "../../shared/tools" +import { AssistantMessageContent } from "./parseAssistantMessage" + +/** + * Parser for assistant messages. Maintains state between chunks + * to avoid reprocessing the entire message on each update. + */ +export class AssistantMessageParser { + private contentBlocks: AssistantMessageContent[] = [] + private currentTextContent: TextContent | undefined = undefined + private currentTextContentStartIndex = 0 + private currentToolUse: ToolUse | undefined = undefined + private currentToolUseStartIndex = 0 + private currentParamName: ToolParamName | undefined = undefined + private currentParamValueStartIndex = 0 + private accumulator = "" + + /** + * Initialize a new AssistantMessageParser instance. + */ + constructor() { + this.reset() + } + + /** + * Reset the parser state. + */ + public reset(): void { + this.contentBlocks = [] + this.currentTextContent = undefined + this.currentTextContentStartIndex = 0 + this.currentToolUse = undefined + this.currentToolUseStartIndex = 0 + this.currentParamName = undefined + this.currentParamValueStartIndex = 0 + this.accumulator = "" + } + + /** + * Returns the current parsed content blocks + */ + + public getContentBlocks(): AssistantMessageContent[] { + // Return a shallow copy to prevent external mutation + return this.contentBlocks.slice() + } + /** + * Process a new chunk of text and update the parser state. + * @param chunk The new chunk of text to process. + */ + public processChunk(chunk: string): AssistantMessageContent[] { + // Store the current length of the accumulator before adding the new chunk + const accumulatorStartLength = this.accumulator.length + + for (let i = 0; i < chunk.length; i++) { + const char = chunk[i] + this.accumulator += char + const currentPosition = accumulatorStartLength + i + + // There should not be a param without a tool use. + if (this.currentToolUse && this.currentParamName) { + const currentParamValue = this.accumulator.slice(this.currentParamValueStartIndex) + const paramClosingTag = `` + // Streamed param content: always write the currently accumulated value + if (currentParamValue.endsWith(paramClosingTag)) { + // End of param value. + // Do not trim content parameters to preserve newlines, but strip first and last newline only + const paramValue = currentParamValue.slice(0, -paramClosingTag.length) + this.currentToolUse.params[this.currentParamName] = + this.currentParamName === "content" + ? paramValue.replace(/^\n/, "").replace(/\n$/, "") + : paramValue.trim() + this.currentParamName = undefined + continue + } else { + // Partial param value is accumulating. + // Write the currently accumulated param content in real time + const partialValue = currentParamValue + this.currentToolUse.params[this.currentParamName] = + this.currentParamName === "content" + ? partialValue.replace(/^\n/, "").replace(/\n$/, "") + : partialValue.trim() + continue + } + } + + // No currentParamName. + + if (this.currentToolUse) { + const currentToolValue = this.accumulator.slice(this.currentToolUseStartIndex) + const toolUseClosingTag = `` + if (currentToolValue.endsWith(toolUseClosingTag)) { + // End of a tool use. + this.currentToolUse.partial = false + + this.currentToolUse = undefined + continue + } else { + const possibleParamOpeningTags = toolParamNames.map((name) => `<${name}>`) + for (const paramOpeningTag of possibleParamOpeningTags) { + if (this.accumulator.endsWith(paramOpeningTag)) { + // Start of a new parameter. + this.currentParamName = paramOpeningTag.slice(1, -1) as ToolParamName + this.currentParamValueStartIndex = this.accumulator.length + break + } + } + + // There's no current param, and not starting a new param. + + // Special case for write_to_file where file contents could + // contain the closing tag, in which case the param would have + // closed and we end up with the rest of the file contents here. + // To work around this, get the string between the starting + // content tag and the LAST content tag. + const contentParamName: ToolParamName = "content" + + if ( + this.currentToolUse.name === "write_to_file" && + this.accumulator.endsWith(``) + ) { + const toolContent = this.accumulator.slice(this.currentToolUseStartIndex) + const contentStartTag = `<${contentParamName}>` + const contentEndTag = `` + const contentStartIndex = toolContent.indexOf(contentStartTag) + contentStartTag.length + const contentEndIndex = toolContent.lastIndexOf(contentEndTag) + + if (contentStartIndex !== -1 && contentEndIndex !== -1 && contentEndIndex > contentStartIndex) { + // Don't trim content to preserve newlines, but strip first and last newline only + this.currentToolUse.params[contentParamName] = toolContent + .slice(contentStartIndex, contentEndIndex) + .replace(/^\n/, "") + .replace(/\n$/, "") + } + } + + // Partial tool value is accumulating. + continue + } + } + + // No currentToolUse. + + let didStartToolUse = false + const possibleToolUseOpeningTags = toolNames.map((name) => `<${name}>`) + + for (const toolUseOpeningTag of possibleToolUseOpeningTags) { + if (this.accumulator.endsWith(toolUseOpeningTag)) { + // Start of a new tool use. + this.currentToolUse = { + type: "tool_use", + name: toolUseOpeningTag.slice(1, -1) as ToolName, + params: {}, + partial: true, + } + + this.currentToolUseStartIndex = this.accumulator.length + + // This also indicates the end of the current text content. + if (this.currentTextContent) { + this.currentTextContent.partial = false + + // Remove the partially accumulated tool use tag from the + // end of text ( block === this.currentToolUse) + if (idx === -1) { + this.contentBlocks.push(this.currentToolUse) + } + + didStartToolUse = true + break + } + } + + if (!didStartToolUse) { + // No tool use, so it must be text either at the beginning or + // between tools. + if (this.currentTextContent === undefined) { + // If this is the first chunk and we're at the beginning of processing, + // set the start index to the current position in the accumulator + this.currentTextContentStartIndex = currentPosition + + // Create a new text content block and add it to contentBlocks + this.currentTextContent = { + type: "text", + content: this.accumulator.slice(this.currentTextContentStartIndex).trim(), + partial: true, + } + + // Add the new text content to contentBlocks immediately + // Ensures it appears in the UI right away + this.contentBlocks.push(this.currentTextContent) + } else { + // Update the existing text content + this.currentTextContent.content = this.accumulator.slice(this.currentTextContentStartIndex).trim() + } + } + } + // Do not call finalizeContentBlocks() here. + // Instead, update any partial blocks in the array and add new ones as they're completed. + // This matches the behavior of the original parseAssistantMessage function. + return this.getContentBlocks() + } + + /** + * Finalize any partial content blocks. + * Should be called after processing the last chunk. + */ + public finalizeContentBlocks(): void { + // Mark all partial blocks as complete + for (const block of this.contentBlocks) { + if (block.partial) { + block.partial = false + } + } + } + + /** + * Process a complete message and return the parsed content blocks. + * @param message The complete message to parse. + * @returns The parsed content blocks. + */ + public parseCompleteMessage(message: string): AssistantMessageContent[] { + this.reset() + return this.processChunk(message) + } +} diff --git a/src/core/assistant-message/__tests__/AssistantMessageParser.spec.ts b/src/core/assistant-message/__tests__/AssistantMessageParser.spec.ts new file mode 100644 index 00000000000..369ff8b7d23 --- /dev/null +++ b/src/core/assistant-message/__tests__/AssistantMessageParser.spec.ts @@ -0,0 +1,324 @@ +// npx vitest src/core/assistant-message/__tests__/AssistantMessageParser.spec.ts + +import { describe, it, expect, beforeEach } from "vitest" +import { AssistantMessageParser } from "../AssistantMessageParser" +import { TextContent, ToolUse } from "../../../shared/tools" +import { toolNames } from "@roo-code/types" + +/** + * Helper to filter out empty text content blocks. + */ +const isEmptyTextContent = (block: any) => block.type === "text" && (block as TextContent).content === "" + +/** + * Helper to simulate streaming by feeding the parser random-sized chunks (1-10 chars). + */ +function streamChunks( + parser: AssistantMessageParser, + message: string, +): ReturnType { + let result: any[] = [] + let i = 0 + while (i < message.length) { + // Random chunk size between 1 and 10, but not exceeding message length + const chunkSize = Math.min(message.length - i, Math.floor(Math.random() * 10) + 1) + const chunk = message.slice(i, i + chunkSize) + result = parser.processChunk(chunk) + i += chunkSize + } + return result +} + +describe("AssistantMessageParser (streaming)", () => { + let parser: AssistantMessageParser + + beforeEach(() => { + parser = new AssistantMessageParser() + }) + + describe("text content streaming", () => { + it("should accumulate a simple text message chunk by chunk", () => { + const message = "Hello, this is a test." + const result = streamChunks(parser, message) + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + type: "text", + content: message, + partial: true, + }) + }) + + it("should accumulate multi-line text message chunk by chunk", () => { + const message = "Line 1\nLine 2\nLine 3" + const result = streamChunks(parser, message) + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + type: "text", + content: message, + partial: true, + }) + }) + }) + + describe("tool use streaming", () => { + it("should parse a tool use with parameter, streamed char by char", () => { + const message = "src/file.ts" + const result = streamChunks(parser, message).filter((block) => !isEmptyTextContent(block)) + expect(result).toHaveLength(1) + const toolUse = result[0] as ToolUse + expect(toolUse.type).toBe("tool_use") + expect(toolUse.name).toBe("read_file") + expect(toolUse.params.path).toBe("src/file.ts") + expect(toolUse.partial).toBe(false) + }) + + it("should mark tool use as partial when not closed", () => { + const message = "src/file.ts" + const result = streamChunks(parser, message).filter((block) => !isEmptyTextContent(block)) + expect(result).toHaveLength(1) + const toolUse = result[0] as ToolUse + expect(toolUse.type).toBe("tool_use") + expect(toolUse.name).toBe("read_file") + expect(toolUse.params.path).toBe("src/file.ts") + expect(toolUse.partial).toBe(true) + }) + + it("should handle a partial parameter in a tool use", () => { + const message = "src/file" + const result = streamChunks(parser, message).filter((block) => !isEmptyTextContent(block)) + expect(result).toHaveLength(1) + const toolUse = result[0] as ToolUse + expect(toolUse.type).toBe("tool_use") + expect(toolUse.name).toBe("read_file") + expect(toolUse.params.path).toBe("src/file") + expect(toolUse.partial).toBe(true) + }) + + it("should handle tool use with multiple parameters streamed", () => { + const message = + "src/file.ts1020" + const result = streamChunks(parser, message).filter((block) => !isEmptyTextContent(block)) + expect(result).toHaveLength(1) + const toolUse = result[0] as ToolUse + expect(toolUse.type).toBe("tool_use") + expect(toolUse.name).toBe("read_file") + expect(toolUse.params.path).toBe("src/file.ts") + expect(toolUse.params.start_line).toBe("10") + expect(toolUse.params.end_line).toBe("20") + expect(toolUse.partial).toBe(false) + }) + }) + + describe("mixed content streaming", () => { + it("should parse text followed by a tool use, streamed", () => { + const message = "Text before tool src/file.ts" + const result = streamChunks(parser, message) + expect(result).toHaveLength(2) + const textContent = result[0] as TextContent + expect(textContent.type).toBe("text") + expect(textContent.content).toBe("Text before tool") + expect(textContent.partial).toBe(false) + const toolUse = result[1] as ToolUse + expect(toolUse.type).toBe("tool_use") + expect(toolUse.name).toBe("read_file") + expect(toolUse.params.path).toBe("src/file.ts") + expect(toolUse.partial).toBe(false) + }) + + it("should parse a tool use followed by text, streamed", () => { + const message = "src/file.tsText after tool" + const result = streamChunks(parser, message).filter((block) => !isEmptyTextContent(block)) + expect(result).toHaveLength(2) + const toolUse = result[0] as ToolUse + expect(toolUse.type).toBe("tool_use") + expect(toolUse.name).toBe("read_file") + expect(toolUse.params.path).toBe("src/file.ts") + expect(toolUse.partial).toBe(false) + const textContent = result[1] as TextContent + expect(textContent.type).toBe("text") + expect(textContent.content).toBe("Text after tool") + expect(textContent.partial).toBe(true) + }) + + it("should parse multiple tool uses separated by text, streamed", () => { + const message = + "First: file1.tsSecond: file2.ts" + const result = streamChunks(parser, message) + expect(result).toHaveLength(4) + expect(result[0].type).toBe("text") + expect((result[0] as TextContent).content).toBe("First:") + expect(result[1].type).toBe("tool_use") + expect((result[1] as ToolUse).name).toBe("read_file") + expect((result[1] as ToolUse).params.path).toBe("file1.ts") + expect(result[2].type).toBe("text") + expect((result[2] as TextContent).content).toBe("Second:") + expect(result[3].type).toBe("tool_use") + expect((result[3] as ToolUse).name).toBe("read_file") + expect((result[3] as ToolUse).params.path).toBe("file2.ts") + }) + }) + + describe("special and edge cases", () => { + it("should handle the write_to_file tool with content that contains closing tags", () => { + const message = `src/file.ts + function example() { + // This has XML-like content: + return true; + } + 5` + + const result = streamChunks(parser, message).filter((block) => !isEmptyTextContent(block)) + + expect(result).toHaveLength(1) + const toolUse = result[0] as ToolUse + expect(toolUse.type).toBe("tool_use") + expect(toolUse.name).toBe("write_to_file") + expect(toolUse.params.path).toBe("src/file.ts") + expect(toolUse.params.line_count).toBe("5") + expect(toolUse.params.content).toContain("function example()") + expect(toolUse.params.content).toContain("// This has XML-like content: ") + expect(toolUse.params.content).toContain("return true;") + expect(toolUse.partial).toBe(false) + }) + it("should handle empty messages", () => { + const message = "" + const result = streamChunks(parser, message) + expect(result).toHaveLength(0) + }) + + it("should handle malformed tool use tags as plain text", () => { + const message = "This has a malformed tag" + const result = streamChunks(parser, message) + expect(result).toHaveLength(1) + expect(result[0].type).toBe("text") + expect((result[0] as TextContent).content).toBe(message) + }) + + it("should handle tool use with no parameters", () => { + const message = "" + const result = streamChunks(parser, message).filter((block) => !isEmptyTextContent(block)) + expect(result).toHaveLength(1) + const toolUse = result[0] as ToolUse + expect(toolUse.type).toBe("tool_use") + expect(toolUse.name).toBe("browser_action") + expect(Object.keys(toolUse.params).length).toBe(0) + expect(toolUse.partial).toBe(false) + }) + + it("should handle a tool use with a parameter containing XML-like content", () => { + const message = "
.*
src
" + const result = streamChunks(parser, message).filter((block) => !isEmptyTextContent(block)) + expect(result).toHaveLength(1) + const toolUse = result[0] as ToolUse + expect(toolUse.type).toBe("tool_use") + expect(toolUse.name).toBe("search_files") + expect(toolUse.params.regex).toBe("
.*
") + expect(toolUse.params.path).toBe("src") + expect(toolUse.partial).toBe(false) + }) + + it("should handle consecutive tool uses without text in between", () => { + const message = "file1.tsfile2.ts" + const result = streamChunks(parser, message).filter((block) => !isEmptyTextContent(block)) + expect(result).toHaveLength(2) + const toolUse1 = result[0] as ToolUse + expect(toolUse1.type).toBe("tool_use") + expect(toolUse1.name).toBe("read_file") + expect(toolUse1.params.path).toBe("file1.ts") + expect(toolUse1.partial).toBe(false) + const toolUse2 = result[1] as ToolUse + expect(toolUse2.type).toBe("tool_use") + expect(toolUse2.name).toBe("read_file") + expect(toolUse2.params.path).toBe("file2.ts") + expect(toolUse2.partial).toBe(false) + }) + + it("should handle whitespace in parameters", () => { + const message = " src/file.ts " + const result = streamChunks(parser, message).filter((block) => !isEmptyTextContent(block)) + expect(result).toHaveLength(1) + const toolUse = result[0] as ToolUse + expect(toolUse.type).toBe("tool_use") + expect(toolUse.name).toBe("read_file") + expect(toolUse.params.path).toBe("src/file.ts") + expect(toolUse.partial).toBe(false) + }) + + it("should handle multi-line parameters", () => { + const message = `file.ts + line 1 + line 2 + line 3 + 3` + const result = streamChunks(parser, message).filter((block) => !isEmptyTextContent(block)) + + expect(result).toHaveLength(1) + const toolUse = result[0] as ToolUse + expect(toolUse.type).toBe("tool_use") + expect(toolUse.name).toBe("write_to_file") + expect(toolUse.params.path).toBe("file.ts") + expect(toolUse.params.content).toContain("line 1") + expect(toolUse.params.content).toContain("line 2") + expect(toolUse.params.content).toContain("line 3") + expect(toolUse.params.line_count).toBe("3") + expect(toolUse.partial).toBe(false) + }) + it("should handle a complex message with multiple content types", () => { + const message = `I'll help you with that task. + + src/index.ts + + Now let's modify the file: + + src/index.ts + // Updated content + console.log("Hello world"); + 2 + + Let's run the code: + + node src/index.ts` + + const result = streamChunks(parser, message) + + expect(result).toHaveLength(6) + + // First text block + expect(result[0].type).toBe("text") + expect((result[0] as TextContent).content).toBe("I'll help you with that task.") + + // First tool use (read_file) + expect(result[1].type).toBe("tool_use") + expect((result[1] as ToolUse).name).toBe("read_file") + + // Second text block + expect(result[2].type).toBe("text") + expect((result[2] as TextContent).content).toContain("Now let's modify the file:") + + // Second tool use (write_to_file) + expect(result[3].type).toBe("tool_use") + expect((result[3] as ToolUse).name).toBe("write_to_file") + + // Third text block + expect(result[4].type).toBe("text") + expect((result[4] as TextContent).content).toContain("Let's run the code:") + + // Third tool use (execute_command) + expect(result[5].type).toBe("tool_use") + expect((result[5] as ToolUse).name).toBe("execute_command") + }) + }) + + describe("finalizeContentBlocks", () => { + it("should mark all partial blocks as complete", () => { + const message = "src/file.ts" + streamChunks(parser, message) + let blocks = parser.getContentBlocks() + // The block may already be partial or not, depending on chunking. + // To ensure the test is robust, we only assert after finalizeContentBlocks. + parser.finalizeContentBlocks() + blocks = parser.getContentBlocks() + expect(blocks[0].partial).toBe(false) + }) + }) +}) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 9df9a225d11..c1964d7fa41 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -72,7 +72,8 @@ import { ToolRepetitionDetector } from "../tools/ToolRepetitionDetector" import { FileContextTracker } from "../context-tracking/FileContextTracker" import { RooIgnoreController } from "../ignore/RooIgnoreController" import { RooProtectedController } from "../protect/RooProtectedController" -import { type AssistantMessageContent, parseAssistantMessage, presentAssistantMessage } from "../assistant-message" +import { type AssistantMessageContent, presentAssistantMessage } from "../assistant-message" +import { AssistantMessageParser } from "../assistant-message/AssistantMessageParser" import { truncateConversationIfNeeded } from "../sliding-window" import { ClineProvider } from "../webview/ClineProvider" import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search-replace" @@ -260,6 +261,7 @@ export class Task extends EventEmitter { didRejectTool = false didAlreadyUseTool = false didCompleteReadingStream = false + assistantMessageParser = new AssistantMessageParser() constructor({ provider, @@ -1534,6 +1536,7 @@ export class Task extends EventEmitter { this.didAlreadyUseTool = false this.presentAssistantMessageLocked = false this.presentAssistantMessageHasPendingUpdates = false + this.assistantMessageParser.reset() await this.diffViewProvider.reset() @@ -1568,9 +1571,9 @@ export class Task extends EventEmitter { case "text": { assistantMessage += chunk.text - // Parse raw assistant message into content blocks. + // Parse raw assistant message chunk into content blocks. const prevLength = this.assistantMessageContent.length - this.assistantMessageContent = parseAssistantMessage(assistantMessage) + this.assistantMessageContent = this.assistantMessageParser.processChunk(chunk.text) if (this.assistantMessageContent.length > prevLength) { // New content we need to present, reset to @@ -1690,6 +1693,10 @@ export class Task extends EventEmitter { // Can't just do this b/c a tool could be in the middle of executing. // this.assistantMessageContent.forEach((e) => (e.partial = false)) + // Now that the stream is complete, finalize any remaining partial content blocks + this.assistantMessageParser.finalizeContentBlocks() + this.assistantMessageContent = this.assistantMessageParser.getContentBlocks() + if (partialBlocks.length > 0) { // If there is content to update then it will complete and // update `this.userMessageContentReady` to true, which we @@ -1703,6 +1710,9 @@ export class Task extends EventEmitter { await this.saveClineMessages() await this.providerRef.deref()?.postStateToWebview() + // Reset parser after each complete conversation round + this.assistantMessageParser.reset() + // Now add to apiConversationHistory. // Need to save assistant responses to file before proceeding to // tool use since user can exit at any moment and we wouldn't be From 59c6bf3c1f05c6d48d416e2ddfe5663540582bea Mon Sep 17 00:00:00 2001 From: axb Date: Thu, 3 Jul 2025 13:44:25 +0800 Subject: [PATCH 02/10] code refactor of AssistantMessageParser --- .../AssistantMessageParser.ts | 25 ++++++------------- .../__tests__/AssistantMessageParser.spec.ts | 24 +++++++++++++++--- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/src/core/assistant-message/AssistantMessageParser.ts b/src/core/assistant-message/AssistantMessageParser.ts index 173267452c4..483a49bced5 100644 --- a/src/core/assistant-message/AssistantMessageParser.ts +++ b/src/core/assistant-message/AssistantMessageParser.ts @@ -76,11 +76,7 @@ export class AssistantMessageParser { } else { // Partial param value is accumulating. // Write the currently accumulated param content in real time - const partialValue = currentParamValue - this.currentToolUse.params[this.currentParamName] = - this.currentParamName === "content" - ? partialValue.replace(/^\n/, "").replace(/\n$/, "") - : partialValue.trim() + this.currentToolUse.params[this.currentParamName] = currentParamValue continue } } @@ -167,6 +163,8 @@ export class AssistantMessageParser { .slice(0, -toolUseOpeningTag.slice(0, -1).length) .trim() + this.currentTextContent.content = this.currentTextContent.content.trim() + // No need to push, currentTextContent is already in contentBlocks this.currentTextContent = undefined } @@ -193,7 +191,7 @@ export class AssistantMessageParser { // Create a new text content block and add it to contentBlocks this.currentTextContent = { type: "text", - content: this.accumulator.slice(this.currentTextContentStartIndex).trim(), + content: this.accumulator.slice(this.currentTextContentStartIndex), partial: true, } @@ -202,7 +200,7 @@ export class AssistantMessageParser { this.contentBlocks.push(this.currentTextContent) } else { // Update the existing text content - this.currentTextContent.content = this.accumulator.slice(this.currentTextContentStartIndex).trim() + this.currentTextContent.content = this.accumulator.slice(this.currentTextContentStartIndex) } } } @@ -222,16 +220,9 @@ export class AssistantMessageParser { if (block.partial) { block.partial = false } + if (block.type === "text" && typeof block.content === "string") { + block.content = block.content.trim() + } } } - - /** - * Process a complete message and return the parsed content blocks. - * @param message The complete message to parse. - * @returns The parsed content blocks. - */ - public parseCompleteMessage(message: string): AssistantMessageContent[] { - this.reset() - return this.processChunk(message) - } } diff --git a/src/core/assistant-message/__tests__/AssistantMessageParser.spec.ts b/src/core/assistant-message/__tests__/AssistantMessageParser.spec.ts index 369ff8b7d23..a727e02ffa3 100644 --- a/src/core/assistant-message/__tests__/AssistantMessageParser.spec.ts +++ b/src/core/assistant-message/__tests__/AssistantMessageParser.spec.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from "vitest" import { AssistantMessageParser } from "../AssistantMessageParser" +import { AssistantMessageContent } from "../parseAssistantMessage" import { TextContent, ToolUse } from "../../../shared/tools" import { toolNames } from "@roo-code/types" @@ -11,17 +12,32 @@ import { toolNames } from "@roo-code/types" const isEmptyTextContent = (block: any) => block.type === "text" && (block as TextContent).content === "" /** - * Helper to simulate streaming by feeding the parser random-sized chunks (1-10 chars). + * Helper to simulate streaming by feeding the parser deterministic "random"-sized chunks (1-10 chars). + * Uses a seeded pseudo-random number generator for deterministic chunking. */ + +// Simple linear congruential generator (LCG) for deterministic pseudo-random numbers +function createSeededRandom(seed: number) { + let state = seed + return { + next: () => { + // LCG parameters from Numerical Recipes + state = (state * 1664525 + 1013904223) % 0x100000000 + return state / 0x100000000 + }, + } +} + function streamChunks( parser: AssistantMessageParser, message: string, ): ReturnType { - let result: any[] = [] + let result: AssistantMessageContent[] = [] let i = 0 + const rng = createSeededRandom(42) // Fixed seed for deterministic tests while (i < message.length) { - // Random chunk size between 1 and 10, but not exceeding message length - const chunkSize = Math.min(message.length - i, Math.floor(Math.random() * 10) + 1) + // Deterministic chunk size between 1 and 10, but not exceeding message length + const chunkSize = Math.min(message.length - i, Math.floor(rng.next() * 10) + 1) const chunk = message.slice(i, i + chunkSize) result = parser.processChunk(chunk) i += chunkSize From d9a986eaea1ed6dd92484d45a1973a6c957e366a Mon Sep 17 00:00:00 2001 From: axb Date: Thu, 10 Jul 2025 14:05:36 +0800 Subject: [PATCH 03/10] make AssistantMessageParser a bit more robust --- .../AssistantMessageParser.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/core/assistant-message/AssistantMessageParser.ts b/src/core/assistant-message/AssistantMessageParser.ts index 483a49bced5..b63b0fc0993 100644 --- a/src/core/assistant-message/AssistantMessageParser.ts +++ b/src/core/assistant-message/AssistantMessageParser.ts @@ -14,6 +14,8 @@ export class AssistantMessageParser { private currentToolUseStartIndex = 0 private currentParamName: ToolParamName | undefined = undefined private currentParamValueStartIndex = 0 + private readonly MAX_ACCUMULATOR_SIZE = 1024 * 1024 // 1MB limit + private readonly MAX_PARAM_LENGTH = 1024 * 100 // 100KB per parameter limit private accumulator = "" /** @@ -50,6 +52,9 @@ export class AssistantMessageParser { * @param chunk The new chunk of text to process. */ public processChunk(chunk: string): AssistantMessageContent[] { + if (this.accumulator.length + chunk.length > this.MAX_ACCUMULATOR_SIZE) { + throw new Error("Assistant message exceeds maximum allowed size") + } // Store the current length of the accumulator before adding the new chunk const accumulatorStartLength = this.accumulator.length @@ -61,6 +66,12 @@ export class AssistantMessageParser { // There should not be a param without a tool use. if (this.currentToolUse && this.currentParamName) { const currentParamValue = this.accumulator.slice(this.currentParamValueStartIndex) + if (currentParamValue.length > this.MAX_PARAM_LENGTH) { + // Reset to a safe state + this.currentParamName = undefined + this.currentParamValueStartIndex = 0 + continue + } const paramClosingTag = `` // Streamed param content: always write the currently accumulated value if (currentParamValue.endsWith(paramClosingTag)) { @@ -97,7 +108,12 @@ export class AssistantMessageParser { for (const paramOpeningTag of possibleParamOpeningTags) { if (this.accumulator.endsWith(paramOpeningTag)) { // Start of a new parameter. - this.currentParamName = paramOpeningTag.slice(1, -1) as ToolParamName + const paramName = paramOpeningTag.slice(1, -1) + if (!toolParamNames.includes(paramName as ToolParamName)) { + // Handle invalid parameter name gracefully + continue + } + this.currentParamName = paramName as ToolParamName this.currentParamValueStartIndex = this.accumulator.length break } From c099d41be6ff851a754d6c6d26c249d6ea103c44 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Wed, 16 Jul 2025 17:11:37 -0500 Subject: [PATCH 04/10] fix: improve AssistantMessageParser with validation, consistent trimming, and test coverage - Add tool name validation to prevent invalid casts - Ensure consistent text content trimming for both new and updated blocks - Add test coverage for MAX_ACCUMULATOR_SIZE and MAX_PARAM_LENGTH limits - Fix auto-approve regression by keeping blocks partial until finalization --- .../AssistantMessageParser.ts | 17 ++++-- .../__tests__/AssistantMessageParser.spec.ts | 56 +++++++++++++++++++ 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/src/core/assistant-message/AssistantMessageParser.ts b/src/core/assistant-message/AssistantMessageParser.ts index b63b0fc0993..364ec603f22 100644 --- a/src/core/assistant-message/AssistantMessageParser.ts +++ b/src/core/assistant-message/AssistantMessageParser.ts @@ -159,10 +159,19 @@ export class AssistantMessageParser { for (const toolUseOpeningTag of possibleToolUseOpeningTags) { if (this.accumulator.endsWith(toolUseOpeningTag)) { + // Extract and validate the tool name + const extractedToolName = toolUseOpeningTag.slice(1, -1) + + // Check if the extracted tool name is valid + if (!toolNames.includes(extractedToolName as ToolName)) { + // Invalid tool name, treat as plain text and continue + continue + } + // Start of a new tool use. this.currentToolUse = { type: "tool_use", - name: toolUseOpeningTag.slice(1, -1) as ToolName, + name: extractedToolName as ToolName, params: {}, partial: true, } @@ -179,8 +188,6 @@ export class AssistantMessageParser { .slice(0, -toolUseOpeningTag.slice(0, -1).length) .trim() - this.currentTextContent.content = this.currentTextContent.content.trim() - // No need to push, currentTextContent is already in contentBlocks this.currentTextContent = undefined } @@ -207,7 +214,7 @@ export class AssistantMessageParser { // Create a new text content block and add it to contentBlocks this.currentTextContent = { type: "text", - content: this.accumulator.slice(this.currentTextContentStartIndex), + content: this.accumulator.slice(this.currentTextContentStartIndex).trim(), partial: true, } @@ -216,7 +223,7 @@ export class AssistantMessageParser { this.contentBlocks.push(this.currentTextContent) } else { // Update the existing text content - this.currentTextContent.content = this.accumulator.slice(this.currentTextContentStartIndex) + this.currentTextContent.content = this.accumulator.slice(this.currentTextContentStartIndex).trim() } } } diff --git a/src/core/assistant-message/__tests__/AssistantMessageParser.spec.ts b/src/core/assistant-message/__tests__/AssistantMessageParser.spec.ts index a727e02ffa3..828bf9ed22b 100644 --- a/src/core/assistant-message/__tests__/AssistantMessageParser.spec.ts +++ b/src/core/assistant-message/__tests__/AssistantMessageParser.spec.ts @@ -325,6 +325,62 @@ describe("AssistantMessageParser (streaming)", () => { }) }) + describe("size limit handling", () => { + it("should throw an error when MAX_ACCUMULATOR_SIZE is exceeded", () => { + // Create a message that exceeds 1MB (MAX_ACCUMULATOR_SIZE) + const largeMessage = "x".repeat(1024 * 1024 + 1) // 1MB + 1 byte + + expect(() => { + parser.processChunk(largeMessage) + }).toThrow("Assistant message exceeds maximum allowed size") + }) + + it("should gracefully handle a parameter that exceeds MAX_PARAM_LENGTH", () => { + // Create a parameter value that exceeds 100KB (MAX_PARAM_LENGTH) + const largeParamValue = "x".repeat(1024 * 100 + 1) // 100KB + 1 byte + const message = `test.txt${largeParamValue}After tool` + + // Process the message in chunks to simulate streaming + let result: AssistantMessageContent[] = [] + let error: Error | null = null + + try { + // Process the opening tags + result = parser.processChunk("test.txt") + + // Process the large parameter value in chunks + const chunkSize = 1000 + for (let i = 0; i < largeParamValue.length; i += chunkSize) { + const chunk = largeParamValue.slice(i, i + chunkSize) + result = parser.processChunk(chunk) + } + + // Process the closing tags and text after + result = parser.processChunk("After tool") + } catch (e) { + error = e as Error + } + + // Should not throw an error + expect(error).toBeNull() + + // Should have processed the content + expect(result.length).toBeGreaterThan(0) + + // The tool use should exist but the content parameter should be reset/empty + const toolUse = result.find((block) => block.type === "tool_use") as ToolUse + expect(toolUse).toBeDefined() + expect(toolUse.name).toBe("write_to_file") + expect(toolUse.params.path).toBe("test.txt") + + // The text after the tool should still be parsed + const textAfter = result.find( + (block) => block.type === "text" && (block as TextContent).content.includes("After tool"), + ) + expect(textAfter).toBeDefined() + }) + }) + describe("finalizeContentBlocks", () => { it("should mark all partial blocks as complete", () => { const message = "src/file.ts" From 5d6e56ffb9acb7ae65fa9f41a03690ba76a7d029 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Thu, 31 Jul 2025 15:36:04 -0500 Subject: [PATCH 05/10] feat: make AssistantMessageParser an experimental feature - Add assistantMessageParser experiment flag (disabled by default) - When experiment is off, use the original parseAssistantMessage function - When experiment is on, use the new AssistantMessageParser class - Ensures backward compatibility with no behavior changes when disabled - All existing tests pass with the experimental flag off --- packages/types/src/experiment.ts | 3 ++- src/core/task/Task.ts | 29 ++++++++++++++++++++++------- src/shared/experiments.ts | 2 ++ 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/packages/types/src/experiment.ts b/packages/types/src/experiment.ts index 5424121d670..6574124629f 100644 --- a/packages/types/src/experiment.ts +++ b/packages/types/src/experiment.ts @@ -6,7 +6,7 @@ import type { Keys, Equals, AssertEqual } from "./type-fu.js" * ExperimentId */ -export const experimentIds = ["powerSteering", "multiFileApplyDiff", "preventFocusDisruption"] as const +export const experimentIds = ["powerSteering", "multiFileApplyDiff", "preventFocusDisruption", "assistantMessageParser"] as const export const experimentIdsSchema = z.enum(experimentIds) @@ -20,6 +20,7 @@ export const experimentsSchema = z.object({ powerSteering: z.boolean().optional(), multiFileApplyDiff: z.boolean().optional(), preventFocusDisruption: z.boolean().optional(), + assistantMessageParser: z.boolean().optional(), }) export type Experiments = z.infer diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index c1964d7fa41..2971277123c 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -72,7 +72,7 @@ import { ToolRepetitionDetector } from "../tools/ToolRepetitionDetector" import { FileContextTracker } from "../context-tracking/FileContextTracker" import { RooIgnoreController } from "../ignore/RooIgnoreController" import { RooProtectedController } from "../protect/RooProtectedController" -import { type AssistantMessageContent, presentAssistantMessage } from "../assistant-message" +import { type AssistantMessageContent, presentAssistantMessage, parseAssistantMessage } from "../assistant-message" import { AssistantMessageParser } from "../assistant-message/AssistantMessageParser" import { truncateConversationIfNeeded } from "../sliding-window" import { ClineProvider } from "../webview/ClineProvider" @@ -261,7 +261,8 @@ export class Task extends EventEmitter { didRejectTool = false didAlreadyUseTool = false didCompleteReadingStream = false - assistantMessageParser = new AssistantMessageParser() + assistantMessageParser?: AssistantMessageParser + isAssistantMessageParserEnabled = false constructor({ provider, @@ -1536,7 +1537,9 @@ export class Task extends EventEmitter { this.didAlreadyUseTool = false this.presentAssistantMessageLocked = false this.presentAssistantMessageHasPendingUpdates = false - this.assistantMessageParser.reset() + if (this.assistantMessageParser) { + this.assistantMessageParser.reset() + } await this.diffViewProvider.reset() @@ -1573,7 +1576,12 @@ export class Task extends EventEmitter { // Parse raw assistant message chunk into content blocks. const prevLength = this.assistantMessageContent.length - this.assistantMessageContent = this.assistantMessageParser.processChunk(chunk.text) + if (this.isAssistantMessageParserEnabled && this.assistantMessageParser) { + this.assistantMessageContent = this.assistantMessageParser.processChunk(chunk.text) + } else { + // Use the old parsing method when experiment is disabled + this.assistantMessageContent = parseAssistantMessage(assistantMessage) + } if (this.assistantMessageContent.length > prevLength) { // New content we need to present, reset to @@ -1694,8 +1702,13 @@ export class Task extends EventEmitter { // this.assistantMessageContent.forEach((e) => (e.partial = false)) // Now that the stream is complete, finalize any remaining partial content blocks - this.assistantMessageParser.finalizeContentBlocks() - this.assistantMessageContent = this.assistantMessageParser.getContentBlocks() + if (this.isAssistantMessageParserEnabled && this.assistantMessageParser) { + this.assistantMessageParser.finalizeContentBlocks() + this.assistantMessageContent = this.assistantMessageParser.getContentBlocks() + } else { + // When using old parser, parse the complete message + this.assistantMessageContent = parseAssistantMessage(assistantMessage) + } if (partialBlocks.length > 0) { // If there is content to update then it will complete and @@ -1711,7 +1724,9 @@ export class Task extends EventEmitter { await this.providerRef.deref()?.postStateToWebview() // Reset parser after each complete conversation round - this.assistantMessageParser.reset() + if (this.assistantMessageParser) { + this.assistantMessageParser.reset() + } // Now add to apiConversationHistory. // Need to save assistant responses to file before proceeding to diff --git a/src/shared/experiments.ts b/src/shared/experiments.ts index 548b55f68c7..4be89afa1a9 100644 --- a/src/shared/experiments.ts +++ b/src/shared/experiments.ts @@ -4,6 +4,7 @@ export const EXPERIMENT_IDS = { MULTI_FILE_APPLY_DIFF: "multiFileApplyDiff", POWER_STEERING: "powerSteering", PREVENT_FOCUS_DISRUPTION: "preventFocusDisruption", + ASSISTANT_MESSAGE_PARSER: "assistantMessageParser", } as const satisfies Record type _AssertExperimentIds = AssertEqual>> @@ -18,6 +19,7 @@ export const experimentConfigsMap: Record = { MULTI_FILE_APPLY_DIFF: { enabled: false }, POWER_STEERING: { enabled: false }, PREVENT_FOCUS_DISRUPTION: { enabled: false }, + ASSISTANT_MESSAGE_PARSER: { enabled: false }, } export const experimentDefault = Object.fromEntries( From 7453a90e4459f97583848db5f944d3aa9be07be3 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Thu, 31 Jul 2025 15:37:26 -0500 Subject: [PATCH 06/10] fix: add assistantMessageParser to experiment tests --- src/shared/__tests__/experiments.spec.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/shared/__tests__/experiments.spec.ts b/src/shared/__tests__/experiments.spec.ts index 607c1e0b04e..21401dc7597 100644 --- a/src/shared/__tests__/experiments.spec.ts +++ b/src/shared/__tests__/experiments.spec.ts @@ -29,6 +29,7 @@ describe("experiments", () => { powerSteering: false, multiFileApplyDiff: false, preventFocusDisruption: false, + assistantMessageParser: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(false) }) @@ -38,6 +39,7 @@ describe("experiments", () => { powerSteering: true, multiFileApplyDiff: false, preventFocusDisruption: false, + assistantMessageParser: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(true) }) @@ -47,6 +49,7 @@ describe("experiments", () => { powerSteering: false, multiFileApplyDiff: false, preventFocusDisruption: false, + assistantMessageParser: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(false) }) From 72ec31eb035e3b8535e92b45ac5d4fc6c7de1ca3 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Thu, 31 Jul 2025 16:30:49 -0500 Subject: [PATCH 07/10] feat: add UI settings and translations for assistantMessageParser experiment - Add English translations for the experimental feature - Add translations for all supported languages (ca, de, es, fr, hi, id, it, ja, ko, nl, pl, pt-BR, ru, tr, vi, zh-CN, zh-TW) - The experiment now appears in the UI settings under Experimental features - Description explains the performance benefits of the new parser --- webview-ui/src/i18n/locales/ca/settings.json | 4 ++++ webview-ui/src/i18n/locales/de/settings.json | 4 ++++ webview-ui/src/i18n/locales/en/settings.json | 6 +++++- webview-ui/src/i18n/locales/es/settings.json | 4 ++++ webview-ui/src/i18n/locales/fr/settings.json | 4 ++++ webview-ui/src/i18n/locales/hi/settings.json | 4 ++++ webview-ui/src/i18n/locales/id/settings.json | 4 ++++ webview-ui/src/i18n/locales/it/settings.json | 4 ++++ webview-ui/src/i18n/locales/ja/settings.json | 4 ++++ webview-ui/src/i18n/locales/ko/settings.json | 4 ++++ webview-ui/src/i18n/locales/nl/settings.json | 4 ++++ webview-ui/src/i18n/locales/pl/settings.json | 4 ++++ webview-ui/src/i18n/locales/pt-BR/settings.json | 4 ++++ webview-ui/src/i18n/locales/ru/settings.json | 4 ++++ webview-ui/src/i18n/locales/tr/settings.json | 4 ++++ webview-ui/src/i18n/locales/vi/settings.json | 4 ++++ webview-ui/src/i18n/locales/zh-CN/settings.json | 7 ++++++- webview-ui/src/i18n/locales/zh-TW/settings.json | 4 ++++ 18 files changed, 75 insertions(+), 2 deletions(-) diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index eb26482d968..ae9a3139b43 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -683,6 +683,10 @@ "PREVENT_FOCUS_DISRUPTION": { "name": "Edició en segon pla", "description": "Quan s'activa, evita la interrupció del focus de l'editor. Les edicions de fitxers es produeixen en segon pla sense obrir la vista diff o robar el focus. Pots continuar treballant sense interrupcions mentre Roo fa canvis. Els fitxers poden obrir-se sense focus per capturar diagnòstics o romandre completament tancats." + }, + "ASSISTANT_MESSAGE_PARSER": { + "name": "Utilitza el nou analitzador de missatges", + "description": "Activa l'analitzador de missatges en streaming experimental que millora el rendiment en respostes llargues processant els missatges de manera més eficient." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 1915b67433a..a3758107251 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -683,6 +683,10 @@ "PREVENT_FOCUS_DISRUPTION": { "name": "Hintergrundbearbeitung", "description": "Verhindert Editor-Fokus-Störungen wenn aktiviert. Dateibearbeitungen erfolgen im Hintergrund ohne Öffnung von Diff-Ansichten oder Fokus-Diebstahl. Du kannst ungestört weiterarbeiten, während Roo Änderungen vornimmt. Dateien können ohne Fokus geöffnet werden, um Diagnosen zu erfassen oder vollständig geschlossen bleiben." + }, + "ASSISTANT_MESSAGE_PARSER": { + "name": "Neuen Nachrichtenparser verwenden", + "description": "Aktiviere den experimentellen Streaming-Nachrichtenparser, der lange Antworten durch effizientere Verarbeitung spürbar schneller macht." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 019d49bc63d..d7f02d8cee9 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -679,9 +679,13 @@ "name": "Enable concurrent file edits", "description": "When enabled, Roo can edit multiple files in a single request. When disabled, Roo must edit files one at a time. Disabling this can help when working with less capable models or when you want more control over file modifications." }, - "PREVENT_FOCUS_DISRUPTION": { +"PREVENT_FOCUS_DISRUPTION": { "name": "Background editing", "description": "Prevent editor focus disruption when enabled. File edits happen in the background without opening diff views or stealing focus. You can continue working uninterrupted while Roo makes changes. Files can be opened without focus to capture diagnostics or kept closed entirely." + }, + "ASSISTANT_MESSAGE_PARSER": { + "name": "Use new message parser", + "description": "Enable the experimental streaming message parser that provides significant performance improvements for long assistant responses by processing messages more efficiently." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 31f12e59c0a..75ea9bb0974 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -683,6 +683,10 @@ "PREVENT_FOCUS_DISRUPTION": { "name": "Edición en segundo plano", "description": "Previene la interrupción del foco del editor cuando está habilitado. Las ediciones de archivos ocurren en segundo plano sin abrir vistas de diferencias o robar el foco. Puedes continuar trabajando sin interrupciones mientras Roo realiza cambios. Los archivos pueden abrirse sin foco para capturar diagnósticos o mantenerse completamente cerrados." + }, + "ASSISTANT_MESSAGE_PARSER": { + "name": "Usar el nuevo analizador de mensajes", + "description": "Activa el analizador de mensajes en streaming experimental que mejora el rendimiento en respuestas largas procesando los mensajes de forma más eficiente." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 439560d0e98..57c95e93ac5 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -683,6 +683,10 @@ "PREVENT_FOCUS_DISRUPTION": { "name": "Édition en arrière-plan", "description": "Empêche la perturbation du focus de l'éditeur lorsqu'activé. Les modifications de fichiers se font en arrière-plan sans ouvrir de vues de différences ou voler le focus. Vous pouvez continuer à travailler sans interruption pendant que Roo effectue des changements. Les fichiers peuvent être ouverts sans focus pour capturer les diagnostics ou rester complètement fermés." + }, + "ASSISTANT_MESSAGE_PARSER": { + "name": "Utiliser le nouveau parseur de messages", + "description": "Active le parseur de messages en streaming expérimental qui accélère nettement les longues réponses en traitant les messages plus efficacement." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 2429ddaa947..477e86a773f 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -684,6 +684,10 @@ "PREVENT_FOCUS_DISRUPTION": { "name": "बैकग्राउंड संपादन", "description": "सक्षम होने पर एडिटर फोकस व्यवधान को रोकता है। फ़ाइल संपादन diff व्यू खोले बिना या फोकस चुराए बिना बैकग्राउंड में होता है। आप Roo के बदलाव करते समय बिना किसी बाधा के काम जारी रख सकते हैं। फ़ाइलें डायग्नोस्टिक्स कैप्चर करने के लिए बिना फोकस के खुल सकती हैं या पूरी तरह बंद रह सकती हैं।" + }, + "ASSISTANT_MESSAGE_PARSER": { + "name": "नए मैसेज पार्सर का उपयोग करें", + "description": "प्रायोगिक स्ट्रीमिंग मैसेज पार्सर सक्षम करें, जो लंबे उत्तरों के लिए संदेशों को अधिक कुशलता से प्रोसेस करके प्रदर्शन को बेहतर बनाता है।" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 5c85ec38568..d26aade2e4c 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -713,6 +713,10 @@ "PREVENT_FOCUS_DISRUPTION": { "name": "Pengeditan Latar Belakang", "description": "Ketika diaktifkan, mencegah gangguan fokus editor. Pengeditan file terjadi di latar belakang tanpa membuka tampilan diff atau mencuri fokus. Anda dapat terus bekerja tanpa gangguan saat Roo melakukan perubahan. File mungkin dibuka tanpa fokus untuk menangkap diagnostik atau tetap tertutup sepenuhnya." + }, + "ASSISTANT_MESSAGE_PARSER": { + "name": "Gunakan parser pesan baru", + "description": "Aktifkan parser pesan streaming eksperimental yang meningkatkan kinerja untuk respons panjang dengan memproses pesan lebih efisien." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 90b95ac5e50..55350e07d23 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -684,6 +684,10 @@ "PREVENT_FOCUS_DISRUPTION": { "name": "Modifica in background", "description": "Previene l'interruzione del focus dell'editor quando abilitato. Le modifiche ai file avvengono in background senza aprire viste di differenze o rubare il focus. Puoi continuare a lavorare senza interruzioni mentre Roo effettua modifiche. I file possono essere aperti senza focus per catturare diagnostiche o rimanere completamente chiusi." + }, + "ASSISTANT_MESSAGE_PARSER": { + "name": "Usa il nuovo parser dei messaggi", + "description": "Abilita il parser di messaggi in streaming sperimentale che migliora nettamente le risposte lunghe elaborando i messaggi in modo più efficiente." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 5370d00688b..02ecf7a08c5 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -684,6 +684,10 @@ "PREVENT_FOCUS_DISRUPTION": { "name": "バックグラウンド編集", "description": "有効にすると、エディターのフォーカス中断を防ぎます。ファイル編集は差分ビューを開いたりフォーカスを奪ったりすることなく、バックグラウンドで行われます。Rooが変更を行っている間も中断されることなく作業を続けることができます。ファイルは診断をキャプチャするためにフォーカスなしで開くか、完全に閉じたままにできます。" + }, + "ASSISTANT_MESSAGE_PARSER": { + "name": "新しいメッセージパーサーを使う", + "description": "実験的なストリーミングメッセージパーサーを有効にします。長い回答をより効率的に処理し、遅延を減らします。" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 1f1bf869d2b..3b87cee8e7e 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -684,6 +684,10 @@ "PREVENT_FOCUS_DISRUPTION": { "name": "백그라운드 편집", "description": "활성화하면 편집기 포커스 방해를 방지합니다. 파일 편집이 diff 뷰를 열거나 포커스를 빼앗지 않고 백그라운드에서 수행됩니다. Roo가 변경사항을 적용하는 동안 방해받지 않고 계속 작업할 수 있습니다. 파일은 진단을 캡처하기 위해 포커스 없이 열거나 완전히 닫힌 상태로 유지할 수 있습니다." + }, + "ASSISTANT_MESSAGE_PARSER": { + "name": "새 메시지 파서 사용", + "description": "실험적 스트리밍 메시지 파서를 활성화합니다. 긴 응답을 더 효율적으로 처리해 지연을 줄입니다." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index d026540e67f..e4bffa38e17 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -684,6 +684,10 @@ "PREVENT_FOCUS_DISRUPTION": { "name": "Achtergrondbewerking", "description": "Voorkomt editor focus verstoring wanneer ingeschakeld. Bestandsbewerkingen gebeuren op de achtergrond zonder diff-weergaven te openen of focus te stelen. Je kunt ononderbroken doorwerken terwijl Roo wijzigingen aanbrengt. Bestanden kunnen zonder focus worden geopend om diagnostiek vast te leggen of volledig gesloten blijven." + }, + "ASSISTANT_MESSAGE_PARSER": { + "name": "Nieuwe berichtparser gebruiken", + "description": "Schakel de experimentele streaming-berichtparser in die lange antwoorden sneller maakt door berichten efficiënter te verwerken." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index b4d64b1e652..fc167eef13d 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -684,6 +684,10 @@ "PREVENT_FOCUS_DISRUPTION": { "name": "Edycja w tle", "description": "Zapobiega zakłócaniu fokusa edytora gdy włączone. Edycje plików odbywają się w tle bez otwierania widoków różnic lub kradzieży fokusa. Możesz kontynuować pracę bez przeszkód podczas gdy Roo wprowadza zmiany. Pliki mogą być otwierane bez fokusa aby przechwycić diagnostykę lub pozostać całkowicie zamknięte." + }, + "ASSISTANT_MESSAGE_PARSER": { + "name": "Użyj nowego parsera wiadomości", + "description": "Włącz eksperymentalny parser wiadomości w strumieniu, który przyspiesza długie odpowiedzi dzięki bardziej wydajnemu przetwarzaniu wiadomości." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index b117212e6d3..46902bb2f00 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -684,6 +684,10 @@ "PREVENT_FOCUS_DISRUPTION": { "name": "Edição em segundo plano", "description": "Previne a interrupção do foco do editor quando habilitado. As edições de arquivos acontecem em segundo plano sem abrir visualizações de diferenças ou roubar o foco. Você pode continuar trabalhando sem interrupções enquanto o Roo faz alterações. Os arquivos podem ser abertos sem foco para capturar diagnósticos ou permanecer completamente fechados." + }, + "ASSISTANT_MESSAGE_PARSER": { + "name": "Usar o novo parser de mensagens", + "description": "Ativa o parser de mensagens em streaming experimental que acelera respostas longas ao processar as mensagens de forma mais eficiente." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index cf657948be6..b71b9e96f94 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -684,6 +684,10 @@ "PREVENT_FOCUS_DISRUPTION": { "name": "Фоновое редактирование", "description": "Предотвращает нарушение фокуса редактора при включении. Редактирование файлов происходит в фоновом режиме без открытия представлений различий или кражи фокуса. Вы можете продолжать работать без перерывов, пока Roo вносит изменения. Файлы могут открываться без фокуса для захвата диагностики или оставаться полностью закрытыми." + }, + "ASSISTANT_MESSAGE_PARSER": { + "name": "Использовать новый парсер сообщений", + "description": "Включите экспериментальный потоковый парсер сообщений, который ускоряет длинные ответы благодаря более эффективной обработке сообщений." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 216da83dff2..151ae161b28 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -684,6 +684,10 @@ "PREVENT_FOCUS_DISRUPTION": { "name": "Arka plan düzenleme", "description": "Etkinleştirildiğinde editör odak kesintisini önler. Dosya düzenlemeleri diff görünümlerini açmadan veya odağı çalmadan arka planda gerçekleşir. Roo değişiklikler yaparken kesintisiz çalışmaya devam edebilirsiniz. Dosyalar tanılamayı yakalamak için odaksız açılabilir veya tamamen kapalı kalabilir." + }, + "ASSISTANT_MESSAGE_PARSER": { + "name": "Yeni mesaj ayrıştırıcıyı kullan", + "description": "Uzun yanıtları daha verimli işleyerek hızlandıran deneysel akış mesaj ayrıştırıcısını etkinleştir." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 6a12c912001..dd58cc21d55 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -684,6 +684,10 @@ "PREVENT_FOCUS_DISRUPTION": { "name": "Chỉnh sửa nền", "description": "Khi được bật, ngăn chặn gián đoạn tiêu điểm trình soạn thảo. Việc chỉnh sửa tệp diễn ra ở nền mà không mở chế độ xem diff hoặc chiếm tiêu điểm. Bạn có thể tiếp tục làm việc không bị gián đoạn trong khi Roo thực hiện thay đổi. Các tệp có thể được mở mà không có tiêu điểm để thu thập chẩn đoán hoặc giữ hoàn toàn đóng." + }, + "ASSISTANT_MESSAGE_PARSER": { + "name": "Dùng bộ phân tích tin nhắn mới", + "description": "Bật bộ phân tích tin nhắn streaming thử nghiệm. Tính năng này tăng tốc phản hồi dài bằng cách xử lý tin nhắn hiệu quả hơn." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 52b8802bc60..1d8debe70f4 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -681,9 +681,14 @@ "name": "启用并发文件编辑", "description": "启用后 Roo 可在单个请求中编辑多个文件。禁用后 Roo 必须逐个编辑文件。禁用此功能有助于使用能力较弱的模型或需要更精确控制文件修改时。" }, - "PREVENT_FOCUS_DISRUPTION": { +"PREVENT_FOCUS_DISRUPTION": { "name": "后台编辑", "description": "启用后防止编辑器焦点干扰。文件编辑在后台进行,不会打开差异视图或抢夺焦点。你可以在 Roo 进行更改时继续不受干扰地工作。文件可以在不获取焦点的情况下打开以捕获诊断信息,或保持完全关闭状态。" + }, + "ASSISTANT_MESSAGE_PARSER": { + "name": "使用新的消息解析器", + "description": "启用实验性的流式消息解析器。通过更高效地处理消息,可显著提升长回复的性能。" + } } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index c90080cb3af..ae641cf41c2 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -684,6 +684,10 @@ "PREVENT_FOCUS_DISRUPTION": { "name": "背景編輯", "description": "啟用後可防止編輯器焦點中斷。檔案編輯會在背景進行,不會開啟 diff 檢視或搶奪焦點。您可以在 Roo 進行變更時繼續不受干擾地工作。檔案可能會在不獲得焦點的情況下開啟以捕獲診斷,或保持完全關閉。" + }, + "ASSISTANT_MESSAGE_PARSER": { + "name": "使用全新訊息解析器", + "description": "啟用實驗性的串流訊息解析器。透過更有效率地處理訊息,能顯著提升長回覆的效能。" } }, "promptCaching": { From c5ec72ef5284f84e0eb5796bb1323d63e4490c9c Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Thu, 31 Jul 2025 16:32:10 -0500 Subject: [PATCH 08/10] fix: add assistantMessageParser to webview test --- webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 7c69f39c2bd..a688cac8851 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -229,6 +229,7 @@ describe("mergeExtensionState", () => { concurrentFileReads: true, multiFileApplyDiff: true, preventFocusDisruption: false, + assistantMessageParser: false, } as Record, } @@ -246,6 +247,7 @@ describe("mergeExtensionState", () => { concurrentFileReads: true, multiFileApplyDiff: true, preventFocusDisruption: false, + assistantMessageParser: false, }) }) }) From 94c316ebc5c750a82d162f9f7b23225a46b7ef9f Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Thu, 31 Jul 2025 17:07:33 -0500 Subject: [PATCH 09/10] fix: remove extra closing brace in zh-CN settings.json --- webview-ui/src/i18n/locales/zh-CN/settings.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 1d8debe70f4..e395fb36290 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -681,7 +681,7 @@ "name": "启用并发文件编辑", "description": "启用后 Roo 可在单个请求中编辑多个文件。禁用后 Roo 必须逐个编辑文件。禁用此功能有助于使用能力较弱的模型或需要更精确控制文件修改时。" }, -"PREVENT_FOCUS_DISRUPTION": { + "PREVENT_FOCUS_DISRUPTION": { "name": "后台编辑", "description": "启用后防止编辑器焦点干扰。文件编辑在后台进行,不会打开差异视图或抢夺焦点。你可以在 Roo 进行更改时继续不受干扰地工作。文件可以在不获取焦点的情况下打开以捕获诊断信息,或保持完全关闭状态。" }, @@ -689,7 +689,6 @@ "name": "使用新的消息解析器", "description": "启用实验性的流式消息解析器。通过更高效地处理消息,可显著提升长回复的性能。" } - } }, "promptCaching": { "label": "禁用提示词缓存", From 6d000bca4f0f4cdc4889272d65c54b03ae143eb2 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Thu, 31 Jul 2025 17:21:13 -0500 Subject: [PATCH 10/10] fix: remove unnecessary re-parsing when experiment is off When using the old parser, parsing already happens during streaming. The finalization step is only needed for the new AssistantMessageParser. This ensures the code executes exactly as before when experiment is disabled. --- src/core/task/Task.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 2971277123c..4f7f6991201 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1705,10 +1705,8 @@ export class Task extends EventEmitter { if (this.isAssistantMessageParserEnabled && this.assistantMessageParser) { this.assistantMessageParser.finalizeContentBlocks() this.assistantMessageContent = this.assistantMessageParser.getContentBlocks() - } else { - // When using old parser, parse the complete message - this.assistantMessageContent = parseAssistantMessage(assistantMessage) } + // When using old parser, no finalization needed - parsing already happened during streaming if (partialBlocks.length > 0) { // If there is content to update then it will complete and