diff --git a/src/core/message-parsing/CodeBlockStateMachine.ts b/src/core/message-parsing/CodeBlockStateMachine.ts
new file mode 100644
index 0000000000..4fab47c65c
--- /dev/null
+++ b/src/core/message-parsing/CodeBlockStateMachine.ts
@@ -0,0 +1,61 @@
+import { ParseContext, CodeBlockState } from "./ParseContext"
+
+export interface ProcessedTextResult {
+ processedText: string
+ suppressXmlParsing: boolean
+ stateChanged: boolean
+ nextIndex: number
+}
+
+export interface CodeBlockBoundary {
+ found: boolean
+ endIndex: number
+ isComplete: boolean
+}
+
+export class CodeBlockStateMachine {
+ /**
+ * Process incoming text and manage code block state transitions
+ */
+ processText(text: string, context: ParseContext): ProcessedTextResult {
+ // Simple approach: scan for ``` patterns and track state
+ let result = ""
+ let i = 0
+ let stateChanged = false
+
+ while (i < text.length) {
+ // Check for ``` pattern at current position
+ if (this.isCodeBlockBoundary(text, i)) {
+ // Found ``` - toggle state
+ if (context.codeBlockState === CodeBlockState.OUTSIDE) {
+ context.codeBlockState = CodeBlockState.INSIDE
+ stateChanged = true
+ } else if (context.codeBlockState === CodeBlockState.INSIDE) {
+ context.codeBlockState = CodeBlockState.OUTSIDE
+ stateChanged = true
+ }
+ // Include the ``` in the result
+ result += "```"
+ i += 3
+ } else {
+ // Regular character
+ result += text[i]
+ i++
+ }
+ }
+
+ return {
+ processedText: result,
+ suppressXmlParsing: context.codeBlockState === CodeBlockState.INSIDE,
+ stateChanged,
+ nextIndex: i,
+ }
+ }
+
+ /**
+ * Check if there's a ``` pattern at the given position
+ */
+ private isCodeBlockBoundary(text: string, pos: number): boolean {
+ return pos <= text.length - 3 && text[pos] === "`" && text[pos + 1] === "`" && text[pos + 2] === "`"
+ }
+}
diff --git a/src/core/message-parsing/DirectiveStreamingParser.ts b/src/core/message-parsing/DirectiveStreamingParser.ts
index 1a2b2eb18d..0e64d64749 100644
--- a/src/core/message-parsing/DirectiveStreamingParser.ts
+++ b/src/core/message-parsing/DirectiveStreamingParser.ts
@@ -1,6 +1,6 @@
import * as sax from "sax"
import { Directive } from "./directives"
-import { ParseContext } from "./ParseContext"
+import { ParseContext, CodeBlockState } from "./ParseContext"
import { DirectiveRegistryFactory } from "./DirectiveRegistryFactory"
import { FallbackParser } from "./FallbackParser"
import { XmlUtils } from "./XmlUtils"
@@ -14,6 +14,10 @@ export class DirectiveStreamingParser {
contentBlocks: [],
hasXmlTags: false,
hasIncompleteXml: XmlUtils.hasIncompleteXml(assistantMessage),
+ codeBlockState: CodeBlockState.OUTSIDE,
+ pendingBackticks: "",
+ codeBlockContent: "",
+ codeBlockStartIndex: -1,
}
const parser = sax.parser(false, { lowercase: true })
@@ -22,28 +26,41 @@ export class DirectiveStreamingParser {
let activeHandler: any = null
parser.onopentag = (node: sax.Tag) => {
- context.hasXmlTags = true
- tagStack.push(node.name)
- const handler = this.registry.getHandler(node.name)
+ // Only process XML tags if NOT inside code block
+ if (context.codeBlockState !== CodeBlockState.INSIDE) {
+ context.hasXmlTags = true
+ tagStack.push(node.name)
+ const handler = this.registry.getHandler(node.name)
- if (handler) {
- activeHandler = handler
- this.registry.getTextHandler().setState("none")
- }
- if (activeHandler) {
- activeHandler.onOpenTag(node, context)
+ if (handler) {
+ activeHandler = handler
+ this.registry.getTextHandler().setState("none")
+ }
+ if (activeHandler) {
+ activeHandler.onOpenTag(node, context)
+ }
+ } else {
+ // Inside code block - treat as plain text
+ const tagText = `<${node.name}${this.attributesToString(node.attributes)}>`
+ this.registry.getTextHandler().onText(tagText, context)
}
}
parser.onclosetag = (tagName: string) => {
- if (activeHandler) {
- activeHandler.onCloseTag(tagName, context)
- if (tagName === activeHandler.tagName) {
- activeHandler = null
- this.registry.getTextHandler().setState("text")
+ if (context.codeBlockState !== CodeBlockState.INSIDE) {
+ // Normal XML processing
+ if (activeHandler) {
+ activeHandler.onCloseTag(tagName, context)
+ if (tagName === activeHandler.tagName) {
+ activeHandler = null
+ this.registry.getTextHandler().setState("text")
+ }
}
+ tagStack.pop()
+ } else {
+ // Inside code block - treat as plain text
+ this.registry.getTextHandler().onText(`${tagName}>`, context)
}
- tagStack.pop()
}
parser.ontext = (text: string) => {
@@ -77,4 +94,16 @@ export class DirectiveStreamingParser {
return context.contentBlocks
}
+
+ /**
+ * Convert SAX node attributes to string representation
+ */
+ private static attributesToString(attributes: { [key: string]: string }): string {
+ if (!attributes || Object.keys(attributes).length === 0) {
+ return ""
+ }
+ return Object.entries(attributes)
+ .map(([key, value]) => ` ${key}="${value}"`)
+ .join("")
+ }
}
diff --git a/src/core/message-parsing/ParseContext.ts b/src/core/message-parsing/ParseContext.ts
index 7868d88445..bc615b5198 100644
--- a/src/core/message-parsing/ParseContext.ts
+++ b/src/core/message-parsing/ParseContext.ts
@@ -1,8 +1,21 @@
import { Directive } from "./directives"
+export enum CodeBlockState {
+ OUTSIDE = "outside", // Normal parsing mode
+ INSIDE = "inside", // Inside code block - suppress XML
+ PARTIAL_START = "partial_start", // Detected partial ``` at start
+ PARTIAL_END = "partial_end", // Detected partial ``` at end
+}
+
export interface ParseContext {
currentText: string
contentBlocks: Directive[]
hasXmlTags: boolean
hasIncompleteXml: boolean
+
+ // Code block state tracking
+ codeBlockState: CodeBlockState
+ pendingBackticks: string // For partial ``` detection
+ codeBlockContent: string // Accumulated content inside code blocks
+ codeBlockStartIndex: number // Track where code block started
}
diff --git a/src/core/message-parsing/__tests__/code-block-state-machine.spec.ts b/src/core/message-parsing/__tests__/code-block-state-machine.spec.ts
new file mode 100644
index 0000000000..311c2055ac
--- /dev/null
+++ b/src/core/message-parsing/__tests__/code-block-state-machine.spec.ts
@@ -0,0 +1,65 @@
+import { suite, test, expect } from "vitest"
+import { CodeBlockStateMachine } from "../CodeBlockStateMachine"
+import { ParseContext, CodeBlockState } from "../ParseContext"
+
+suite("CodeBlockStateMachine", () => {
+ function createContext(): ParseContext {
+ return {
+ currentText: "",
+ contentBlocks: [],
+ hasXmlTags: false,
+ hasIncompleteXml: false,
+ codeBlockState: CodeBlockState.OUTSIDE,
+ pendingBackticks: "",
+ codeBlockContent: "",
+ codeBlockStartIndex: -1,
+ }
+ }
+
+ test("should detect complete code block boundary", () => {
+ const stateMachine = new CodeBlockStateMachine()
+ const context = createContext()
+ const input = "```\ncode content\n```"
+
+ const result = stateMachine.processText(input, context)
+
+ expect(context.codeBlockState).toBe(CodeBlockState.OUTSIDE)
+ expect(result.processedText).toBe("```\ncode content\n```")
+ })
+
+ test("should handle false positive backticks", () => {
+ const stateMachine = new CodeBlockStateMachine()
+ const context = createContext()
+
+ // Single backtick should not trigger code block
+ const result1 = stateMachine.processText("text `single` more", context)
+ expect(context.codeBlockState).toBe(CodeBlockState.OUTSIDE)
+
+ // Two backticks should not trigger code block
+ const result2 = stateMachine.processText("text ``double`` more", context)
+ expect(context.codeBlockState).toBe(CodeBlockState.OUTSIDE)
+ })
+
+ test("should toggle state correctly for code blocks", () => {
+ const stateMachine = new CodeBlockStateMachine()
+ const context = createContext()
+
+ // Start outside
+ expect(context.codeBlockState).toBe(CodeBlockState.OUTSIDE)
+
+ // Process opening ```
+ const result1 = stateMachine.processText("```", context)
+ expect(context.codeBlockState).toBe(CodeBlockState.INSIDE)
+ expect(result1.suppressXmlParsing).toBe(true)
+
+ // Process content inside
+ const result2 = stateMachine.processText("content", context)
+ expect(context.codeBlockState).toBe(CodeBlockState.INSIDE)
+ expect(result2.suppressXmlParsing).toBe(true)
+
+ // Process closing ```
+ const result3 = stateMachine.processText("```", context)
+ expect(context.codeBlockState).toBe(CodeBlockState.OUTSIDE)
+ expect(result3.suppressXmlParsing).toBe(false)
+ })
+})
diff --git a/src/core/message-parsing/__tests__/directive-streaming-parser.spec.ts b/src/core/message-parsing/__tests__/directive-streaming-parser.spec.ts
index 66012c24a0..32f17e12ab 100644
--- a/src/core/message-parsing/__tests__/directive-streaming-parser.spec.ts
+++ b/src/core/message-parsing/__tests__/directive-streaming-parser.spec.ts
@@ -78,4 +78,49 @@ suite("DirectiveStreamingParser", () => {
} as TextDirective,
])
})
+
+ test("should handle mixed content with code blocks and directives", () => {
+ const input =
+ "Some text ```\n\nThis is code\n\n```\nMore text\n\nThis is a real directive\ninfo\n"
+ const result = DirectiveStreamingParser.parse(input)
+ expect(result).toHaveLength(2)
+ expect(result[0].type).toBe("text")
+ expect((result[0] as TextDirective).content).toContain("")
+ expect(result[1].type).toBe("log_message")
+ })
+
+ test("should handle multiple code blocks in single message", () => {
+ const input =
+ "Text ```\ncontent1\n``` middle ```\ncontent2\n``` end"
+ const result = DirectiveStreamingParser.parse(input)
+ expect(result).toEqual([
+ {
+ type: "text",
+ content:
+ "Text ```\ncontent1\n``` middle ```\ncontent2\n``` end",
+ partial: true,
+ } as TextDirective,
+ ])
+ })
+
+ test("should handle nested backticks inside code blocks", () => {
+ const input = "Text ```\nSome `nested` backticks here\n``` end"
+ const result = DirectiveStreamingParser.parse(input)
+ expect(result).toEqual([
+ {
+ type: "text",
+ content: "Text ```\nSome `nested` backticks here\n``` end",
+ partial: true,
+ } as TextDirective,
+ ])
+ })
+
+ test("should not treat incomplete backticks as code blocks", () => {
+ const input =
+ "Text with `single` and ``double`` backticks Should be parsedinfo"
+ const result = DirectiveStreamingParser.parse(input)
+ expect(result).toHaveLength(2)
+ expect(result[0].type).toBe("text")
+ expect(result[1].type).toBe("log_message")
+ })
})
diff --git a/src/core/message-parsing/handlers/TextDirectiveHandler.ts b/src/core/message-parsing/handlers/TextDirectiveHandler.ts
index 7162a76438..8cd1eb7485 100644
--- a/src/core/message-parsing/handlers/TextDirectiveHandler.ts
+++ b/src/core/message-parsing/handlers/TextDirectiveHandler.ts
@@ -1,10 +1,12 @@
import { BaseDirectiveHandler } from "./BaseDirectiveHandler"
-import { ParseContext } from "../ParseContext"
+import { ParseContext, CodeBlockState } from "../ParseContext"
import { TextDirective } from "../directives"
+import { CodeBlockStateMachine } from "../CodeBlockStateMachine"
export class TextDirectiveHandler extends BaseDirectiveHandler {
readonly tagName = "text"
private currentState: "text" | "none" = "text"
+ private stateMachine = new CodeBlockStateMachine()
override canHandle(tagName: string): boolean {
return false // Text handler is fallback
@@ -12,7 +14,12 @@ export class TextDirectiveHandler extends BaseDirectiveHandler {
override onText(text: string, context: ParseContext): void {
if (this.currentState === "text") {
- context.currentText += text
+ // Process text through the code block state machine
+ const result = this.stateMachine.processText(text, context)
+
+ // Always add processed text to current text
+ // The suppressXmlParsing flag is used by the parser to decide whether to process XML tags
+ context.currentText += result.processedText
}
}
@@ -21,6 +28,19 @@ export class TextDirectiveHandler extends BaseDirectiveHandler {
}
override onEnd(context: ParseContext): void {
+ // Handle any remaining code block content
+ if (context.codeBlockContent) {
+ context.currentText += context.codeBlockContent
+ context.codeBlockContent = ""
+ }
+
+ // Handle any pending backticks that weren't completed
+ if (context.pendingBackticks) {
+ context.currentText += context.pendingBackticks
+ context.pendingBackticks = ""
+ }
+
+ // Create text directive if we have content
if (context.currentText.trim()) {
context.contentBlocks.push({
type: "text",
@@ -29,4 +49,11 @@ export class TextDirectiveHandler extends BaseDirectiveHandler {
} as TextDirective)
}
}
+
+ /**
+ * Check if we're currently inside a code block
+ */
+ isInsideCodeBlock(context: ParseContext): boolean {
+ return context.codeBlockState === CodeBlockState.INSIDE
+ }
}